fix: Use encoded native byte array buffer to filter Litho components

This commit is contained in:
oSumAtrIX 2026-03-11 02:00:45 +01:00
parent 5aec6cd3b7
commit 7495528815
No known key found for this signature in database
GPG key ID: A9B3094ACDB604B4
31 changed files with 781 additions and 731 deletions

View file

@ -0,0 +1,13 @@
package app.revanced.extension.shared;
public final class ConversionContext {
/**
* Interface to use obfuscated methods.
*/
public interface ContextInterface {
// Methods implemented by patch.
StringBuilder patch_getPathBuilder();
String patch_getIdentifier();
}
}

View file

@ -376,7 +376,7 @@ public class Utils {
/**
* Checks if a specific app package is installed and enabled on the device.
*
* @param packageName The application package name to check (e.g., "app.morphe.android.apps.youtube.music").
* @param packageName The application package name to check (e.g., "app.revanced.android.apps.youtube.music").
* @return True if the package is installed and enabled, false otherwise.
*/
public static boolean isPackageEnabled(String packageName) {
@ -396,6 +396,18 @@ public class Utils {
return false;
}
}
public static boolean startsWithAny(String value, String...targets) {
if (isNotEmpty(value)) {
for (String string : targets) {
if (isNotEmpty(string) && value.startsWith(string)) {
return true;
}
}
}
return false;
}
public interface MatchFilter<T> {
boolean matches(T object);
}

View file

@ -5,12 +5,10 @@ import androidx.annotation.Nullable;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import app.revanced.extension.shared.ConversionContext.ContextInterface;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.patches.litho.FilterGroup.StringFilterGroup;
import app.revanced.extension.shared.settings.BaseSettings;
import app.revanced.extension.shared.StringTrieSearch;
@ -123,11 +121,6 @@ public final class LithoFilterPatch {
*/
private static final boolean EXTRACT_IDENTIFIER_FROM_BUFFER = false;
/**
* Turns on additional logging, used for development purposes only.
*/
public static final boolean DEBUG_EXTRACT_IDENTIFIER_FROM_BUFFER = false;
/**
* String suffix for components.
* Can be any of: ".eml", ".eml-fe", ".e-b", ".eml-js", "e-js-b"
@ -146,19 +139,6 @@ public final class LithoFilterPatch {
*/
private static final ThreadLocal<byte[]> bufferThreadLocal = new ThreadLocal<>();
/**
* Identifier to protocol buffer mapping. Only used for 20.22+.
* Thread local is needed because filtering is multithreaded and each thread can load
* a different component with the same identifier.
*/
private static final ThreadLocal<Map<String, byte[]>> identifierToBufferThread = new ThreadLocal<>();
/**
* Global shared buffer. Used only if the buffer is not found in the ThreadLocal.
*/
private static final Map<String, byte[]> identifierToBufferGlobal
= Collections.synchronizedMap(createIdentifierToBufferMap());
private static final StringTrieSearch pathSearchTree = new StringTrieSearch();
private static final StringTrieSearch identifierSearchTree = new StringTrieSearch();
@ -211,126 +191,11 @@ public final class LithoFilterPatch {
}
}
private static Map<String, byte[]> createIdentifierToBufferMap() {
// It's unclear how many items should be cached. This is a guess.
return Utils.createSizeRestrictedMap(100);
}
/**
* Helper function that differs from {@link Character#isDigit(char)}
* as this only matches ascii and not Unicode numbers.
*/
private static boolean isAsciiNumber(byte character) {
return '0' <= character && character <= '9';
}
private static boolean isAsciiLowerCaseLetter(byte character) {
return 'a' <= character && character <= 'z';
}
/**
* Injection point. Called off the main thread.
* Targets 20.22+
*/
public static void setProtoBuffer(byte[] buffer) {
if (DEBUG_EXTRACT_IDENTIFIER_FROM_BUFFER) {
StringBuilder builder = new StringBuilder();
LithoFilterParameters.findAsciiStrings(builder, buffer);
Logger.printDebug(() -> "New buffer: " + builder);
}
// The identifier always seems to start very close to the buffer start.
// Highest identifier start index ever observed is 50, with most around 30 to 40.
// The buffer can be very large with up to 200kb has been observed,
// so the search is restricted to only the start.
final int maxBufferStartIndex = 500; // 10x expected upper bound.
// Could use Boyer-Moore-Horspool since the string is ASCII and has a limited number of
// unique characters, but it seems to be slower since the extra overhead of checking the
// bad character array negates any performance gain of skipping a few extra subsearches.
int emlIndex = -1;
final int emlStringLength = LITHO_COMPONENT_EXTENSION_BYTES.length;
final int lastBufferIndexToCheckFrom = Math.min(maxBufferStartIndex, buffer.length - emlStringLength);
for (int i = 0; i < lastBufferIndexToCheckFrom; i++) {
boolean match = true;
for (int j = 0; j < emlStringLength; j++) {
if (buffer[i + j] != LITHO_COMPONENT_EXTENSION_BYTES[j]) {
match = false;
break;
}
}
if (match) {
emlIndex = i;
break;
}
}
if (emlIndex < 0) {
// Buffer is not used for creating a new litho component.
if (DEBUG_EXTRACT_IDENTIFIER_FROM_BUFFER) {
Logger.printDebug(() -> "Could not find eml index");
}
return;
}
int startIndex = emlIndex - 1;
while (startIndex > 0) {
final byte character = buffer[startIndex];
int startIndexFinal = startIndex;
if (isAsciiLowerCaseLetter(character) || isAsciiNumber(character) || character == '_') {
// Valid character for the first path element.
startIndex--;
} else {
startIndex++;
break;
}
}
// Strip away any numbers on the start of the identifier, which can
// be from random data in the buffer before the identifier starts.
while (true) {
final byte character = buffer[startIndex];
if (isAsciiNumber(character)) {
startIndex++;
} else {
break;
}
}
// Find the pipe character after the identifier.
int endIndex = -1;
for (int i = emlIndex, length = buffer.length; i < length; i++) {
if (buffer[i] == '|') {
endIndex = i;
break;
}
}
if (endIndex < 0) {
if (BaseSettings.DEBUG.get()) {
Logger.printException(() -> "Debug: Could not find buffer identifier");
}
return;
}
String identifier = new String(buffer, startIndex, endIndex - startIndex, StandardCharsets.US_ASCII);
if (DEBUG_EXTRACT_IDENTIFIER_FROM_BUFFER) {
Logger.printDebug(() -> "Found buffer for identifier: " + identifier);
}
identifierToBufferGlobal.put(identifier, buffer);
Map<String, byte[]> map = identifierToBufferThread.get();
if (map == null) {
map = createIdentifierToBufferMap();
identifierToBufferThread.set(map);
}
map.put(identifier, buffer);
}
/**
* Injection point. Called off the main thread.
* Targets 20.21 and lower.
*/
public static void setProtoBuffer(@Nullable ByteBuffer buffer) {
public static void setProtobufBuffer(@Nullable ByteBuffer buffer) {
if (buffer == null || !buffer.hasArray()) {
// It appears the buffer can be cleared out just before the call to #filter()
// Ignore this null value and retain the last buffer that was set.
@ -347,44 +212,18 @@ public final class LithoFilterPatch {
/**
* Injection point.
*/
public static boolean isFiltered(String identifier, @Nullable String accessibilityId,
@Nullable String accessibilityText, StringBuilder pathBuilder) {
public static boolean isFiltered(ContextInterface contextInterface, @Nullable byte[] bytes,
@Nullable String accessibilityId, @Nullable String accessibilityText) {
try {
String identifier = contextInterface.patch_getIdentifier();
StringBuilder pathBuilder = contextInterface.patch_getPathBuilder();
if (identifier.isEmpty() || pathBuilder.length() == 0) {
return false;
}
byte[] buffer = null;
if (EXTRACT_IDENTIFIER_FROM_BUFFER) {
final int pipeIndex = identifier.indexOf('|');
if (pipeIndex >= 0) {
// If the identifier contains no pipe, then it's not an ".eml" identifier
// and the buffer is not uniquely identified. Typically, this only happens
// for subcomponents where buffer filtering is not used.
String identifierKey = identifier.substring(0, pipeIndex);
var map = identifierToBufferThread.get();
if (map != null) {
buffer = map.get(identifierKey);
}
if (buffer == null) {
// Buffer for thread local not found. Use the last buffer found from any thread.
buffer = identifierToBufferGlobal.get(identifierKey);
if (DEBUG_EXTRACT_IDENTIFIER_FROM_BUFFER && buffer == null) {
// No buffer is found for some components, such as
// shorts_lockup_cell.eml on channel profiles.
// For now, just ignore this and filter without a buffer.
if (BaseSettings.DEBUG.get()) {
Logger.printException(() -> "Debug: Could not find buffer for identifier: " + identifier);
}
}
}
}
} else {
buffer = bufferThreadLocal.get();
}
byte[] buffer = EXTRACT_IDENTIFIER_FROM_BUFFER
? bytes
: bufferThreadLocal.get();
// Potentially the buffer may have been null or never set up until now.
// Use an empty buffer so the litho id/path filters that do not use a buffer still work.

View file

@ -0,0 +1,49 @@
package app.revanced.extension.youtube.patches;
import androidx.annotation.NonNull;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.youtube.shared.PlayerType;
/**
* Here is an unintended behavior:
* <p>
* 1. The user does not hide Shorts in the Subscriptions tab, but hides them otherwise.
* 2. Goes to the Subscriptions tab and scrolls to where Shorts is.
* 3. Opens a regular video.
* 4. Minimizes the video and turns off the screen.
* 5. Turns the screen on and maximizes the video.
* 6. Shorts belonging to related videos are not hidden.
* <p>
* Here is an explanation of this special issue:
* <p>
* When the user minimizes the video, turns off the screen, and then turns it back on,
* the components below the player are reloaded, and at this moment the PlayerType is [WATCH_WHILE_MINIMIZED].
* (Shorts belonging to related videos are also reloaded)
* Since the PlayerType is [WATCH_WHILE_MINIMIZED] at this moment, the navigation tab is checked.
* (Even though PlayerType is [WATCH_WHILE_MINIMIZED], this is a Shorts belonging to a related video)
* <p>
* As a workaround for this special issue, if a video actionbar is detected, which is one of the components below the player,
* it is treated as being in the same state as [WATCH_WHILE_MAXIMIZED].
*/
@SuppressWarnings("unused")
public class LayoutReloadObserverPatch {
private static final String COMPACTIFY_VIDEO_ACTION_BAR_PREFIX = "compactify_video_action_bar.e";
private static final String VIDEO_ACTION_BAR_PREFIX = "video_action_bar.e";
public static final AtomicBoolean isActionBarVisible = new AtomicBoolean(false);
public static void onLazilyConvertedElementLoaded(@NonNull String identifier,
@NonNull List<Object> treeNodeResultList) {
if (!Utils.startsWithAny(identifier, COMPACTIFY_VIDEO_ACTION_BAR_PREFIX, VIDEO_ACTION_BAR_PREFIX)) {
return;
}
if (PlayerType.getCurrent() == PlayerType.WATCH_WHILE_MINIMIZED &&
isActionBarVisible.compareAndSet(false, true)) {
Utils.runOnMainThreadDelayed(() -> isActionBarVisible.compareAndSet(true, false), 500);
}
}
}

View file

@ -0,0 +1,33 @@
package app.revanced.extension.youtube.patches;
import java.util.List;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.ConversionContext.ContextInterface;
@SuppressWarnings("unused")
public class LazilyConvertedElementPatch {
private static final String LAZILY_CONVERTED_ELEMENT = "LazilyConvertedElement";
/**
* Injection point.
*/
public static void onTreeNodeResultLoaded(ContextInterface contextInterface, List<Object> treeNodeResultList) {
if (treeNodeResultList == null || treeNodeResultList.isEmpty()) {
return;
}
String firstElement = treeNodeResultList.get(0).toString();
if (!LAZILY_CONVERTED_ELEMENT.equals(firstElement)) {
return;
}
String identifier = contextInterface.patch_getIdentifier();
if (Utils.isNotEmpty(identifier)) {
onLazilyConvertedElementLoaded(identifier, treeNodeResultList);
}
}
private static void onLazilyConvertedElementLoaded(String identifier, List<Object> treeNodeResultList) {
// Code added by patch.
}
}

View file

@ -54,6 +54,6 @@ public class PlayerControlsPatch {
// noinspection EmptyMethod
private static void fullscreenButtonVisibilityChanged(boolean isVisible) {
// Code added during patching.
// Code added by patch.
}
}

View file

@ -19,24 +19,25 @@ import app.revanced.extension.shared.Utils;
import app.revanced.extension.youtube.patches.litho.ReturnYouTubeDislikeFilter;
import app.revanced.extension.youtube.returnyoutubedislike.ReturnYouTubeDislike;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.shared.ConversionContext.ContextInterface;
import app.revanced.extension.youtube.shared.PlayerType;
/**
* Handles all interaction of UI patch components.
*
* <p>
* Known limitation:
* The implementation of Shorts litho requires blocking the loading the first Short until RYD has completed.
* This is because it modifies the dislikes text synchronously, and if the RYD fetch has
* not completed yet then the UI will be temporarily frozen.
*
* <p>
* A (yet to be implemented) solution that fixes this problem. Any one of:
* - Modify patch to hook onto the Shorts Litho TextView, and update the dislikes text asynchronously.
* - Find a way to force Litho to rebuild it's component tree,
* and use that hook to force the shorts dislikes to update after the fetch is completed.
* and use that hook to force the shorts dislikes to update after the fetch is completed.
* - Hook into the dislikes button image view, and replace the dislikes thumb down image with a
* generated image of the number of dislikes, then update the image asynchronously. This Could
* also be used for the regular video player to give a better UI layout and completely remove
* the need for the Rolling Number patches.
* generated image of the number of dislikes, then update the image asynchronously. This Could
* also be used for the regular video player to give a better UI layout and completely remove
* the need for the Rolling Number patches.
*/
@SuppressWarnings("unused")
public class ReturnYouTubeDislikePatch {
@ -100,7 +101,7 @@ public class ReturnYouTubeDislikePatch {
/**
* Injection point.
*
* <p>
* Logs if new litho text layout is used.
*/
public static boolean useNewLithoTextCreation(boolean useNewLithoTextCreation) {
@ -113,28 +114,28 @@ public class ReturnYouTubeDislikePatch {
/**
* Injection point.
*
* <p>
* For Litho segmented buttons and Litho Shorts player.
*/
@NonNull
public static CharSequence onLithoTextLoaded(@NonNull Object conversionContext,
@NonNull CharSequence original) {
return onLithoTextLoaded(conversionContext, original, false);
public static CharSequence onLithoTextLoaded(ContextInterface contextInterface,
CharSequence original) {
return onLithoTextLoaded(contextInterface, original, false);
}
/**
* Called when a litho text component is initially created,
* and also when a Span is later reused again (such as scrolling off/on screen).
*
* <p>
* This method is sometimes called on the main thread, but it is usually called _off_ the main thread.
* This method can be called multiple times for the same UI element (including after dislikes was added).
*
* @param original Original char sequence was created or reused by Litho.
* @param original Original char sequence was created or reused by Litho.
* @param isRollingNumber If the span is for a Rolling Number.
* @return The original char sequence (if nothing should change), or a replacement char sequence that contains dislikes.
*/
@NonNull
private static CharSequence onLithoTextLoaded(@NonNull Object conversionContext,
private static CharSequence onLithoTextLoaded(ContextInterface contextInterface,
@NonNull CharSequence original,
boolean isRollingNumber) {
try {
@ -142,17 +143,16 @@ public class ReturnYouTubeDislikePatch {
return original;
}
String conversionContextString = conversionContext.toString();
if (Settings.RYD_ENABLED.get()) { // FIXME: Remove this.
Logger.printDebug(() -> "RYD conversion context: " + conversionContext);
}
if (isRollingNumber && !conversionContextString.contains("video_action_bar.e")) {
String identifier = contextInterface.patch_getIdentifier();
if (isRollingNumber && (identifier == null || !identifier.contains("video_action_bar.e"))) {
return original;
}
if (conversionContextString.contains("segmented_like_dislike_button.e")) {
StringBuilder pathBuilder = contextInterface.patch_getPathBuilder();
String path = pathBuilder.toString();
if (path.contains("segmented_like_dislike_button.e")) {
// Regular video.
ReturnYouTubeDislike videoData = currentVideoData;
if (videoData == null) {
@ -169,13 +169,11 @@ public class ReturnYouTubeDislikePatch {
return original; // No need to check for Shorts in the context.
}
if (Utils.containsAny(conversionContextString,
"|shorts_dislike_button.e", "|reel_dislike_button.e")) {
if (Utils.containsAny(path, "|shorts_dislike_button.e", "|reel_dislike_button.e")) {
return getShortsSpan(original, true);
}
if (Utils.containsAny(conversionContextString,
"|shorts_like_button.e", "|reel_like_button.e")) {
if (Utils.containsAny(path, "|shorts_like_button.e", "|reel_like_button.e")) {
if (!Utils.containsNumber(original)) {
Logger.printDebug(() -> "Replacing hidden likes count");
return getShortsSpan(original, false);
@ -230,10 +228,9 @@ public class ReturnYouTubeDislikePatch {
/**
* Injection point.
*/
public static String onRollingNumberLoaded(@NonNull Object conversionContext,
@NonNull String original) {
public static String onRollingNumberLoaded(ContextInterface contextInterface, String original) {
try {
CharSequence replacement = onLithoTextLoaded(conversionContext, original, true);
CharSequence replacement = onLithoTextLoaded(contextInterface, original, true);
String replacementString = replacement.toString();
if (!replacementString.equals(original)) {
@ -248,7 +245,7 @@ public class ReturnYouTubeDislikePatch {
/**
* Injection point.
*
* <p>
* Called for all usage of Rolling Number.
* Modifies the measured String text width to include the left separator and padding, if needed.
*/
@ -489,7 +486,7 @@ public class ReturnYouTubeDislikePatch {
/**
* Injection point.
*
* <p>
* Called when the user likes or dislikes.
*
* @param vote int that matches {@link Vote#value}

View file

@ -139,22 +139,14 @@ public final class DescriptionComponentsFilter extends Filter {
// If the description panel is opened in a Shorts, PlayerType is 'HIDDEN',
// so 'PlayerType.getCurrent().isMaximizedOrFullscreen()' does not guarantee that the description panel is open.
// Instead, use the engagement id to check if the description panel is opened.
if (!EngagementPanel.isDescription()
// The user can minimize the player while the engagement panel is open.
//
// In this case, the engagement panel is treated as open.
// (If the player is dismissed, the engagement panel is considered closed)
//
// Therefore, the following exceptions can occur:
// 1. The user opened a regular video and opened the description panel.
// 2. The 'horizontalShelf' elements were hidden.
// 3. The user minimized the player.
// 4. The user manually refreshed the library tab without dismissing the player.
// 5. Since the engagement panel is treated as open, the history shelf is filtered.
//
// To handle these exceptions, filtering is not performed even when the player is minimized.
|| PlayerType.getCurrent() == PlayerType.WATCH_WHILE_MINIMIZED
) {
if (!EngagementPanel.isDescription()) {
return false;
}
// PlayerType when the description panel is opened: NONE, HIDDEN,
// WATCH_WHILE_MAXIMIZED, WATCH_WHILE_FULLSCREEN, WATCH_WHILE_SLIDING_MAXIMIZED_FULLSCREEN.
PlayerType playerType = PlayerType.getCurrent();
if (!playerType.isNoneOrHidden() && !playerType.isMaximizedOrFullscreen()) {
return false;
}

View file

@ -0,0 +1,102 @@
package app.revanced.extension.youtube.patches.litho;
import static app.revanced.extension.youtube.patches.LayoutReloadObserverPatch.isActionBarVisible;
import app.revanced.extension.shared.patches.litho.Filter;
import app.revanced.extension.shared.patches.litho.FilterGroup.ByteArrayFilterGroup;
import app.revanced.extension.shared.patches.litho.FilterGroup.StringFilterGroup;
import app.revanced.extension.shared.patches.litho.FilterGroupList.ByteArrayFilterGroupList;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.youtube.shared.EngagementPanel;
import app.revanced.extension.youtube.shared.NavigationBar;
import app.revanced.extension.youtube.shared.NavigationBar.NavigationButton;
import app.revanced.extension.youtube.shared.PlayerType;
@SuppressWarnings("unused")
final class HorizontalShelvesFilter extends Filter {
private final ByteArrayFilterGroupList descriptionBuffers = new ByteArrayFilterGroupList();
private final ByteArrayFilterGroupList generalBuffers = new ByteArrayFilterGroupList();
public HorizontalShelvesFilter() {
StringFilterGroup horizontalShelves = new StringFilterGroup(null, "horizontal_shelf.e");
addPathCallbacks(horizontalShelves);
descriptionBuffers.addAll(
new ByteArrayFilterGroup(
Settings.HIDE_ATTRIBUTES_SECTION,
// May no longer work on v20.31+, even though the component is still there.
"cell_video_attribute"
),
new ByteArrayFilterGroup(
Settings.HIDE_FEATURED_PLACES_SECTION,
"yt_fill_experimental_star",
"yt_fill_star"
),
new ByteArrayFilterGroup(
Settings.HIDE_GAMING_SECTION,
"yt_outline_experimental_gaming",
"yt_outline_gaming"
),
new ByteArrayFilterGroup(
Settings.HIDE_MUSIC_SECTION,
"yt_outline_experimental_audio",
"yt_outline_audio"
),
new ByteArrayFilterGroup(
Settings.HIDE_QUIZZES_SECTION,
"post_base_wrapper_slim"
)
);
generalBuffers.addAll(
new ByteArrayFilterGroup(
Settings.HIDE_CREATOR_STORE_SHELF,
"shopping_item_card_list"
),
new ByteArrayFilterGroup(
Settings.HIDE_PLAYABLES,
"FEmini_app_destination"
),
new ByteArrayFilterGroup(
Settings.HIDE_TICKET_SHELF,
"ticket_item.e"
)
);
}
private boolean hideShelves() {
if (!Settings.HIDE_HORIZONTAL_SHELVES.get()) {
return false;
}
// Must check player type first, as search bar can be active behind the player.
if (PlayerType.getCurrent().isMaximizedOrFullscreen() || isActionBarVisible.get()) {
return false;
}
// Must check second, as search can be from any tab.
if (NavigationBar.isSearchBarActive()) {
return true;
}
return NavigationButton.getSelectedNavigationButton() != NavigationButton.LIBRARY;
}
@Override
public boolean isFiltered(String identifier, String accessibility, String path, byte[] buffer,
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
if (contentIndex != 0) {
return false;
}
if (generalBuffers.check(buffer).isFiltered()) {
return true;
}
if (EngagementPanel.isDescription()) {
PlayerType playerType = PlayerType.getCurrent();
// PlayerType when the description panel is opened: NONE, HIDDEN,
// WATCH_WHILE_MAXIMIZED, WATCH_WHILE_FULLSCREEN, WATCH_WHILE_SLIDING_MAXIMIZED_FULLSCREEN.
if (!playerType.isMaximizedOrFullscreen() && !playerType.isNoneOrHidden()) {
return false;
}
return descriptionBuffers.check(buffer).isFiltered();
}
return hideShelves();
}
}

View file

@ -7,7 +7,6 @@ import android.graphics.drawable.Drawable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.util.Pair;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
@ -16,16 +15,12 @@ import android.widget.TextView;
import androidx.annotation.Nullable;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import app.revanced.extension.shared.ByteTrieSearch;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.StringTrieSearch;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.BooleanSetting;
import app.revanced.extension.shared.patches.litho.Filter;
import app.revanced.extension.shared.patches.litho.FilterGroup.ByteArrayFilterGroup;
import app.revanced.extension.shared.patches.litho.FilterGroup.StringFilterGroup;
@ -38,10 +33,6 @@ import app.revanced.extension.youtube.shared.PlayerType;
@SuppressWarnings("unused")
public final class LayoutComponentsFilter extends Filter {
private static final StringTrieSearch mixPlaylistsContextExceptions = new StringTrieSearch(
"V.ED", // Playlist browseId.
"java.lang.ref.WeakReference"
);
private static final ByteArrayFilterGroup mixPlaylistsBufferExceptions = new ByteArrayFilterGroup(
null,
"cell_description_body",
@ -84,11 +75,6 @@ public final class LayoutComponentsFilter extends Filter {
private final StringFilterGroup chipBar;
private final StringFilterGroup channelProfile;
private final StringFilterGroupList channelProfileGroupList;
private final StringFilterGroup horizontalShelves;
private final ByteArrayFilterGroup playablesBuffer;
private final ByteArrayFilterGroup ticketShelfBuffer;
private final ByteArrayFilterGroup playerShoppingShelfBuffer;
private final ByteTrieSearch descriptionSearch;
public LayoutComponentsFilter() {
exceptions.addPatterns(
@ -264,12 +250,6 @@ public final class LayoutComponentsFilter extends Filter {
"mini_game_card.e"
);
// Playable horizontal shelf header.
playablesBuffer = new ByteArrayFilterGroup(
null,
"FEmini_app_destination"
);
final var quickActions = new StringFilterGroup(
Settings.HIDE_QUICK_ACTIONS,
"quick_actions"
@ -317,7 +297,7 @@ public final class LayoutComponentsFilter extends Filter {
);
final var forYouShelf = new StringFilterGroup(
Settings.HIDE_FOR_YOU_SHELF,
Settings.HIDE_HORIZONTAL_SHELVES,
"mixed_content_shelf"
);
@ -361,51 +341,6 @@ public final class LayoutComponentsFilter extends Filter {
)
);
horizontalShelves = new StringFilterGroup(
null, // Setting is checked in isFiltered()
"horizontal_video_shelf.e",
"horizontal_shelf.e",
"horizontal_shelf_inline.e",
"horizontal_tile_shelf.e"
);
ticketShelfBuffer = new ByteArrayFilterGroup(
null,
"ticket_item.e"
);
playerShoppingShelfBuffer = new ByteArrayFilterGroup(
null,
"shopping_item_card_list"
);
// Work around for unique situation where filtering is based on the setting,
// but it must not fall over to other filters if the setting is _not_ enabled.
// This is only needed for the horizontal shelf that is used so extensively everywhere.
descriptionSearch = new ByteTrieSearch();
List.of(
new Pair<>(Settings.HIDE_FEATURED_PLACES_SECTION, "yt_fill_star"),
new Pair<>(Settings.HIDE_FEATURED_PLACES_SECTION, "yt_fill_experimental_star"),
new Pair<>(Settings.HIDE_GAMING_SECTION, "yt_outline_gaming"),
new Pair<>(Settings.HIDE_GAMING_SECTION, "yt_outline_experimental_gaming"),
new Pair<>(Settings.HIDE_MUSIC_SECTION, "yt_outline_audio"),
new Pair<>(Settings.HIDE_MUSIC_SECTION, "yt_outline_experimental_audio"),
new Pair<>(Settings.HIDE_QUIZZES_SECTION, "post_base_wrapper_slim"),
// May no longer work on v20.31+, even though the component is still there.
new Pair<>(Settings.HIDE_ATTRIBUTES_SECTION, "cell_video_attribute")
).forEach(pair -> {
BooleanSetting setting = pair.first;
descriptionSearch.addPattern(pair.second.getBytes(StandardCharsets.UTF_8),
(textSearched, matchedStartIndex, matchedLength, callbackParameter) -> {
//noinspection unchecked
AtomicReference<Boolean> hide = (AtomicReference<Boolean>) callbackParameter;
hide.set(setting.get());
return true;
}
);
}
);
addPathCallbacks(
artistCard,
audioTrackButton,
@ -422,7 +357,6 @@ public final class LayoutComponentsFilter extends Filter {
emergencyBox,
expandableMetadata,
forYouShelf,
horizontalShelves,
imageShelf,
infoPanel,
latestPosts,
@ -484,47 +418,6 @@ public final class LayoutComponentsFilter extends Filter {
&& joinMembershipButton.check(buffer).isFiltered();
}
// Horizontal shelves are used everywhere in the app. And to prevent the generic "hide shelves"
// from incorrectly hiding other stuff that has its own hide filters,
// the more specific shelf filters must check first _and_ they must halt falling over
// to other filters if the buffer matches but the setting is off.
if (matchedGroup == horizontalShelves) {
if (contentIndex != 0) return false;
AtomicReference<Boolean> descriptionFilterResult = new AtomicReference<>(null);
if (descriptionSearch.matches(buffer, descriptionFilterResult)) {
return descriptionFilterResult.get();
}
// Check if others are off before searching.
final boolean hideShelves = Settings.HIDE_HORIZONTAL_SHELVES.get();
final boolean hideTickets = Settings.HIDE_TICKET_SHELF.get();
final boolean hidePlayables = Settings.HIDE_PLAYABLES.get();
final boolean hidePlayerShoppingShelf = Settings.HIDE_CREATOR_STORE_SHELF.get();
if (!hideShelves && !hideTickets && !hidePlayables && !hidePlayerShoppingShelf)
return false;
if (ticketShelfBuffer.check(buffer).isFiltered()) return hideTickets;
if (playablesBuffer.check(buffer).isFiltered()) return hidePlayables;
if (playerShoppingShelfBuffer.check(buffer).isFiltered())
return hidePlayerShoppingShelf;
// 20.31+ when exiting fullscreen after watching for a while or when resuming the app,
// then sometimes the buffer isn't correct and the player shopping shelf is shown.
// If filtering reaches this point then there are no more shelves that could be in the player.
// If shopping shelves are set to hidden and the player is active, then assume
// it's the shopping shelf.
if (hidePlayerShoppingShelf) {
PlayerType type = PlayerType.getCurrent();
if (type == PlayerType.WATCH_WHILE_MAXIMIZED || type == PlayerType.WATCH_WHILE_FULLSCREEN
|| type == PlayerType.WATCH_WHILE_SLIDING_MAXIMIZED_FULLSCREEN) {
return true;
}
}
return hideShelves && hideShelves();
}
if (matchedGroup == chipBar) {
return contentIndex == 0 && NavigationButton.getSelectedNavigationButton() == NavigationButton.LIBRARY;
}
@ -536,7 +429,7 @@ public final class LayoutComponentsFilter extends Filter {
* Injection point.
* Called from a different place then the other filters.
*/
public static boolean filterMixPlaylists(Object conversionContext, @Nullable byte[] buffer) {
public static boolean filterMixPlaylists(@Nullable byte[] buffer) {
// Edit: This hook may no longer be needed, and mix playlist filtering
// might be possible using the existing litho filters.
try {
@ -551,13 +444,7 @@ public final class LayoutComponentsFilter extends Filter {
if (mixPlaylists.check(buffer).isFiltered()
// Prevent hiding the description of some videos accidentally.
&& !mixPlaylistsBufferExceptions.check(buffer).isFiltered()
// Prevent playlist items being hidden, if a mix playlist is present in it.
// Check last since it requires creating a context string.
//
// FIXME: The conversion context passed in does not always generate a valid toString.
// This string check may no longer be needed, or the patch may be broken.
&& !mixPlaylistsContextExceptions.matches(conversionContext.toString())) {
&& !mixPlaylistsBufferExceptions.check(buffer).isFiltered()) {
Logger.printDebug(() -> "Filtered mix playlist");
return true;
}
@ -757,31 +644,6 @@ public final class LayoutComponentsFilter extends Filter {
: original;
}
private static boolean hideShelves() {
// Horizontal shelves are used for music/game links in video descriptions,
// such as https://youtube.com/watch?v=W8kI1na3S2M
if (PlayerType.getCurrent().isMaximizedOrFullscreen()) {
return false;
}
// Must check search bar after player type, since search results
// can be in the background behind an open player.
if (NavigationBar.isSearchBarActive()) {
return true;
}
// Do not hide if the navigation back button is visible,
// otherwise the content shelves in the explore/music/courses pages are hidden.
if (NavigationBar.isBackButtonVisible()) {
return false;
}
// Check navigation button last.
// Only filter if the library tab is not selected.
// This check is important as the shelf layout is used for the library tab playlists.
return NavigationButton.getSelectedNavigationButton() != NavigationButton.LIBRARY;
}
/**
* Injection point.
*/
@ -919,8 +781,8 @@ public final class LayoutComponentsFilter extends Filter {
/**
* Injection point.
*
* @param typedString Keywords typed in the search bar.
* @return Whether the setting is enabled and the typed string is empty.
* @param typedString Keywords typed in the search bar.
* @return Whether the setting is enabled and the typed string is empty.
*/
public static boolean hideYouMayLikeSection(String typedString) {
return Settings.HIDE_YOU_MAY_LIKE_SECTION.get()
@ -932,13 +794,13 @@ public final class LayoutComponentsFilter extends Filter {
/**
* Injection point.
*
* @param searchTerm This class contains information related to search terms.
* The {@code toString()} method of this class overrides the search term.
* @param endpoint Endpoint related with the search term.
* For search history, this value is:
* '/complete/deleteitems?client=youtube-android-pb&delq=${searchTerm}&deltok=${token}'.
* For search suggestions, this value is null or empty.
* @return Whether search term is a search history or not.
* @param searchTerm This class contains information related to search terms.
* The {@code toString()} method of this class overrides the search term.
* @param endpoint Endpoint related with the search term.
* For search history, this value is:
* '/complete/deleteitems?client=youtube-android-pb&delq=${searchTerm}&deltok=${token}'.
* For search suggestions, this value is null or empty.
* @return Whether search term is a search history or not.
*/
public static boolean isSearchHistory(Object searchTerm, String endpoint) {
boolean isSearchHistory = endpoint != null && endpoint.contains("/delete");

View file

@ -1,26 +1,22 @@
package app.revanced.extension.youtube.patches.litho;
import static app.revanced.extension.youtube.patches.LayoutReloadObserverPatch.isActionBarVisible;
import static app.revanced.extension.youtube.shared.NavigationBar.NavigationButton;
import android.view.View;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.patches.litho.Filter;
import app.revanced.extension.shared.patches.litho.FilterGroup.*;
import app.revanced.extension.shared.patches.litho.FilterGroup.ByteArrayFilterGroup;
import app.revanced.extension.shared.patches.litho.FilterGroupList.ByteArrayFilterGroupList;
import app.revanced.extension.shared.patches.litho.LithoFilterPatch;
import app.revanced.extension.shared.settings.BooleanSetting;
import app.revanced.extension.shared.patches.litho.FilterGroupList.StringFilterGroupList;
import com.google.android.libraries.youtube.rendering.ui.pivotbar.PivotBar;
import java.lang.ref.WeakReference;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.youtube.patches.VersionCheckPatch;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.youtube.shared.EngagementPanel;
import app.revanced.extension.youtube.shared.NavigationBar;
@ -34,16 +30,6 @@ public final class ShortsFilter extends Filter {
"reel_action_bar.", // Regular Shorts.
"reels_player_overlay_layout." // Shorts ads.
};
private static final Map<Integer, BooleanSetting> REEL_ACTION_BUTTONS_MAP = new HashMap<>() {
{
// Like button and Dislike button can be hidden with Litho filter.
// put(0, Settings.HIDE_SHORTS_LIKE_BUTTON);
// put(1, Settings.HIDE_SHORTS_DISLIKE_BUTTON);
put(2, Settings.HIDE_SHORTS_COMMENTS_BUTTON);
put(3, Settings.HIDE_SHORTS_SHARE_BUTTON);
put(4, Settings.HIDE_SHORTS_REMIX_BUTTON);
}
};
private final String REEL_CHANNEL_BAR_PATH = "reel_channel_bar.e";
/**
@ -90,8 +76,8 @@ public final class ShortsFilter extends Filter {
private final ByteArrayFilterGroupList suggestedActionsBuffer = new ByteArrayFilterGroupList();
private final StringFilterGroup shortsActionBar;
private final StringFilterGroup videoActionButton;
private final ByteArrayFilterGroupList videoActionButtonBuffer = new ByteArrayFilterGroupList();
private final StringFilterGroup shortsActionButton;
private final StringFilterGroupList shortsActionButtonGroupList = new StringFilterGroupList();
public ShortsFilter() {
//
@ -289,7 +275,7 @@ public final class ShortsFilter extends Filter {
"yt_outline_template_add_"
);
videoActionButton = new StringFilterGroup(
shortsActionButton = new StringFilterGroup(
null,
// Can be any of:
// button.eml
@ -308,36 +294,26 @@ public final class ShortsFilter extends Filter {
shortsCompactFeedVideo, shelfHeaderPath, joinButton, subscribeButton, paidPromotionLabel,
livePreview, suggestedAction, pausedOverlayButtons, channelBar, infoPanel, previewComment,
autoDubbedLabel, fullVideoLinkLabel, videoTitle, useSoundButton, soundButton, stickers,
reelCarousel, reelSoundMetadata, likeFountain, likeButton, dislikeButton
reelCarousel, reelSoundMetadata, likeFountain, likeButton, dislikeButton, shortsActionBar
);
// Legacy hiding of Shorts action buttons. Because of 20.31+ buffer changes
// it's currently not possible to hide these using buffer filtering.
// See alternative hiding strategy in hideActionButtons().
if (!VersionCheckPatch.IS_20_22_OR_GREATER) {
addPathCallbacks(shortsActionBar);
//
// All other action buttons.
//
videoActionButtonBuffer.addAll(
new ByteArrayFilterGroup(
Settings.HIDE_SHORTS_COMMENTS_BUTTON,
"reel_comment_button",
"youtube_shorts_comment_outline"
),
new ByteArrayFilterGroup(
Settings.HIDE_SHORTS_SHARE_BUTTON,
"reel_share_button",
"youtube_shorts_share_outline"
),
new ByteArrayFilterGroup(
Settings.HIDE_SHORTS_REMIX_BUTTON,
"reel_remix_button",
"youtube_shorts_remix_outline"
)
);
}
//
// All other action buttons.
//
shortsActionButtonGroupList.addAll(
new StringFilterGroup(
Settings.HIDE_SHORTS_COMMENTS_BUTTON,
"id.reel_comment_button"
),
new StringFilterGroup(
Settings.HIDE_SHORTS_SHARE_BUTTON,
"id.reel_share_button"
),
new StringFilterGroup(
Settings.HIDE_SHORTS_REMIX_BUTTON,
"id.reel_remix_button"
)
);
//
// Suggested actions.
@ -482,8 +458,10 @@ public final class ShortsFilter extends Filter {
// Video action buttons (comment, share, remix) have the same path.
// Like and dislike are separate path filters and don't require buffer searching.
if (matchedGroup == shortsActionBar) {
return videoActionButton.check(path).isFiltered()
&& videoActionButtonBuffer.check(buffer).isFiltered();
if (shortsActionButton.check(path).isFiltered()) {
return shortsActionButtonGroupList.check(accessibility).isFiltered();
}
return false;
}
if (matchedGroup == suggestedAction) {
@ -525,7 +503,7 @@ public final class ShortsFilter extends Filter {
}
// Must check player type first, as search bar can be active behind the player.
if (PlayerType.getCurrent().isMaximizedOrFullscreen()) {
if (PlayerType.getCurrent().isMaximizedOrFullscreen() || isActionBarVisible.get()) {
return EngagementPanel.isDescription()
? hideVideoDescription // Player video description panel opened.
: hideHome; // For now, consider Shorts under video player the same as the home feed.
@ -555,58 +533,6 @@ public final class ShortsFilter extends Filter {
};
}
/**
* Injection point.
* <p>
* Hide action buttons by index.
* <p>
* Regular video action buttons vary in order by video, country, and account.
* Therefore, hiding buttons by index may hide unintended buttons.
* <p>
* Shorts action buttons are almost always in the same order.
* (From top to bottom: Like, Dislike, Comment, Share, Remix).
* Therefore, we can hide Shorts action buttons by index.
*
* @param pathBuilder Same as pathBuilder used in {@link LithoFilterPatch}.
* @param treeNodeResultList List containing Litho components.
*/
public static void hideActionButtons(StringBuilder pathBuilder, List<Object> treeNodeResultList) {
try {
if (pathBuilder == null || pathBuilder.length() == 0 || treeNodeResultList == null) {
return;
}
int size = treeNodeResultList.size();
// The minimum size of the target List is 4.
if (size < 4) {
return;
}
String path = pathBuilder.toString();
if (!Utils.containsAny(path, REEL_ACTION_BAR_PATHS)
// Regular Shorts: [ComponentType, ComponentType, ComponentType, ComponentType, ComponentType]
// Shorts ads: [ComponentType, ComponentType, ComponentType, ComponentType] (No Remix button)
|| !COMPONENT_TYPE.equals(treeNodeResultList.get(0).toString())) {
return;
}
// Removing elements without iterating through the list in reverse order will throw an exception.
for (int i = size - 1; i > -1; i--) {
// treeNodeResult is each button.
Object treeNodeResult = treeNodeResultList.get(i);
if (treeNodeResult != null) {
BooleanSetting setting = REEL_ACTION_BUTTONS_MAP.get(i);
if (setting != null && setting.get()) {
int finalI = i;
Logger.printDebug(() -> "Hiding action button by index: " + finalI + ", key: " + setting.key);
treeNodeResultList.remove(i);
}
}
}
} catch (Exception ex) {
Logger.printException(() -> "hideActionButtons failed", ex);
}
}
/**
* Injection point.
*/

View file

@ -147,7 +147,6 @@ public class Settings extends YouTubeAndMusicSettings {
public static final BooleanSetting HIDE_CHANNEL_TAB = new BooleanSetting("revanced_hide_channel_tab", FALSE);
public static final StringSetting HIDE_CHANNEL_TAB_FILTER_STRINGS = new StringSetting("revanced_hide_channel_tab_filter_strings", "", true, parent(HIDE_CHANNEL_TAB));
public static final BooleanSetting HIDE_COMMUNITY_BUTTON = new BooleanSetting("revanced_hide_community_button", TRUE);
public static final BooleanSetting HIDE_FOR_YOU_SHELF = new BooleanSetting("revanced_hide_for_you_shelf", FALSE);
public static final BooleanSetting HIDE_JOIN_BUTTON = new BooleanSetting("revanced_hide_join_button", FALSE);
public static final BooleanSetting HIDE_LINKS_PREVIEW = new BooleanSetting("revanced_hide_links_preview", TRUE);
public static final BooleanSetting HIDE_MEMBERS_SHELF = new BooleanSetting("revanced_hide_members_shelf", TRUE);

View file

@ -138,5 +138,6 @@ enum class PlayerType {
fun isMaximizedOrFullscreen(): Boolean {
return this == WATCH_WHILE_MAXIMIZED || this == WATCH_WHILE_FULLSCREEN
|| this == WATCH_WHILE_SLIDING_MAXIMIZED_FULLSCREEN
}
}