diff --git a/app/src/main/java/app/revanced/integrations/patches/components/AdsFilter.java b/app/src/main/java/app/revanced/integrations/patches/components/AdsFilter.java index 4e1bdfd8..3797f53a 100644 --- a/app/src/main/java/app/revanced/integrations/patches/components/AdsFilter.java +++ b/app/src/main/java/app/revanced/integrations/patches/components/AdsFilter.java @@ -2,21 +2,25 @@ package app.revanced.integrations.patches.components; import android.view.View; + +import androidx.annotation.Nullable; + import app.revanced.integrations.settings.SettingsEnum; import app.revanced.integrations.utils.ReVancedUtils; +import app.revanced.integrations.utils.StringTrieSearch; public final class AdsFilter extends Filter { - private final String[] exceptions; + private final StringTrieSearch exceptions = new StringTrieSearch(); public AdsFilter() { - exceptions = new String[]{ + exceptions.addPatterns( "home_video_with_context", // Don't filter anything in the home page video component. "related_video_with_context", // Don't filter anything in the related video component. "comment_thread", // Don't filter anything in the comments. "|comment.", // Don't filter anything in the comments replies. - "library_recent_shelf", - }; + "library_recent_shelf" + ); final var buttonedAd = new StringFilterGroup( SettingsEnum.HIDE_BUTTONED_ADS, @@ -95,11 +99,12 @@ public final class AdsFilter extends Filter { } @Override - public boolean isFiltered(final String path, final String identifier, final byte[] _protobufBufferArray) { - if (ReVancedUtils.containsAny(path, exceptions)) + public boolean isFiltered(String path, @Nullable String identifier, byte[] protobufBufferArray, + FilterGroupList matchedList, FilterGroup matchedGroup, int matchedIndex) { + if (exceptions.matches(path)) return false; - return super.isFiltered(path, identifier, _protobufBufferArray); + return super.isFiltered(path, identifier, protobufBufferArray, matchedList, matchedGroup, matchedIndex); } /** diff --git a/app/src/main/java/app/revanced/integrations/patches/components/ButtonsFilter.java b/app/src/main/java/app/revanced/integrations/patches/components/ButtonsFilter.java index 42a4bdf2..5722e640 100644 --- a/app/src/main/java/app/revanced/integrations/patches/components/ButtonsFilter.java +++ b/app/src/main/java/app/revanced/integrations/patches/components/ButtonsFilter.java @@ -1,5 +1,7 @@ package app.revanced.integrations.patches.components; +import androidx.annotation.Nullable; + import app.revanced.integrations.settings.SettingsEnum; final class ButtonsFilter extends Filter { @@ -33,7 +35,8 @@ final class ButtonsFilter extends Filter { SettingsEnum.HIDE_ACTION_BUTTONS, "ContainerType|video_action_button", "|CellType|CollectionType|CellType|ContainerType|button.eml|" - ) + ), + actionBarRule ); } @@ -45,10 +48,12 @@ final class ButtonsFilter extends Filter { } @Override - public boolean isFiltered(final String path, final String identifier, final byte[] _protobufBufferArray) { - if (isEveryFilterGroupEnabled()) - if (actionBarRule.check(identifier).isFiltered()) return true; + public boolean isFiltered(String path, @Nullable String identifier, byte[] protobufBufferArray, + FilterGroupList matchedList, FilterGroup matchedGroup, int matchedIndex) { + if (matchedGroup == actionBarRule) { + return isEveryFilterGroupEnabled(); + } - return super.isFiltered(path, identifier, _protobufBufferArray); + return super.isFiltered(path, identifier, protobufBufferArray, matchedList, matchedGroup, matchedIndex); } } diff --git a/app/src/main/java/app/revanced/integrations/patches/components/LayoutComponentsFilter.java b/app/src/main/java/app/revanced/integrations/patches/components/LayoutComponentsFilter.java index f88b23e5..53860b9e 100644 --- a/app/src/main/java/app/revanced/integrations/patches/components/LayoutComponentsFilter.java +++ b/app/src/main/java/app/revanced/integrations/patches/components/LayoutComponentsFilter.java @@ -2,15 +2,16 @@ package app.revanced.integrations.patches.components; import android.os.Build; -import androidx.annotation.RequiresApi; -import app.revanced.integrations.settings.SettingsEnum; -import app.revanced.integrations.utils.ReVancedUtils; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import app.revanced.integrations.settings.SettingsEnum; +import app.revanced.integrations.utils.StringTrieSearch; @RequiresApi(api = Build.VERSION_CODES.N) public final class LayoutComponentsFilter extends Filter { - private final String[] exceptions; - + private final StringTrieSearch exceptions = new StringTrieSearch(); private final CustomFilterGroup custom; private static final ByteArrayAsStringFilterGroup mixPlaylists = new ByteArrayAsStringFilterGroup( @@ -20,13 +21,13 @@ public final class LayoutComponentsFilter extends Filter { @RequiresApi(api = Build.VERSION_CODES.N) public LayoutComponentsFilter() { - exceptions = new String[]{ + exceptions.addPatterns( "home_video_with_context", "related_video_with_context", "comment_thread", // skip filtering anything in the comments "|comment.", // skip filtering anything in the comments replies - "library_recent_shelf", - }; + "library_recent_shelf" + ); custom = new CustomFilterGroup( SettingsEnum.CUSTOM_FILTER, @@ -160,7 +161,8 @@ public final class LayoutComponentsFilter extends Filter { artistCard, imageShelf, subscribersCommunityGuidelines, - channelMemberShelf + channelMemberShelf, + custom ); this.identifierFilterGroups.addAll( @@ -170,19 +172,21 @@ public final class LayoutComponentsFilter extends Filter { } @Override - public boolean isFiltered(final String path, final String identifier, final byte[] _protobufBufferArray) { - if (custom.isEnabled() && custom.check(path).isFiltered()) - return true; - - if (ReVancedUtils.containsAny(path, exceptions)) + public boolean isFiltered(String path, @Nullable String identifier, byte[] protobufBufferArray, + FilterGroupList matchedList, FilterGroup matchedGroup, int matchedIndex) { + if (matchedGroup != custom && exceptions.matches(path)) return false; // Exceptions are not filtered. - return super.isFiltered(path, identifier, _protobufBufferArray); + return super.isFiltered(path, identifier, protobufBufferArray, matchedList, matchedGroup, matchedIndex); } - // Called from a different place then the other filters. + /** + * Injection point. + * + * Called from a different place then the other filters. + */ public static boolean filterMixPlaylists(final byte[] bytes) { - return mixPlaylists.isEnabled() && mixPlaylists.check(bytes).isFiltered(); + return mixPlaylists.check(bytes).isFiltered(); } } diff --git a/app/src/main/java/app/revanced/integrations/patches/components/LithoFilterPatch.java b/app/src/main/java/app/revanced/integrations/patches/components/LithoFilterPatch.java index 8e10eea9..d475bd21 100644 --- a/app/src/main/java/app/revanced/integrations/patches/components/LithoFilterPatch.java +++ b/app/src/main/java/app/revanced/integrations/patches/components/LithoFilterPatch.java @@ -1,31 +1,30 @@ package app.revanced.integrations.patches.components; import android.os.Build; - import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; +import app.revanced.integrations.settings.SettingsEnum; +import app.revanced.integrations.utils.*; import java.nio.ByteBuffer; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Iterator; -import java.util.Spliterator; +import java.util.*; import java.util.function.Consumer; -import app.revanced.integrations.settings.SettingsEnum; -import app.revanced.integrations.utils.LogHelper; -import app.revanced.integrations.utils.ReVancedUtils; - abstract class FilterGroup { final static class FilterGroupResult { - private final boolean filtered; - private final SettingsEnum setting; + SettingsEnum setting; + boolean filtered; - public FilterGroupResult(final SettingsEnum setting, final boolean filtered) { + FilterGroupResult(SettingsEnum setting, boolean filtered) { this.setting = setting; this.filtered = filtered; } + /** + * A null value if the group has no setting, + * or if no match is returned from {@link FilterGroupList#check(Object)}. + */ public SettingsEnum getSetting() { return setting; } @@ -48,51 +47,87 @@ abstract class FilterGroup { public FilterGroup(final SettingsEnum setting, final T... filters) { this.setting = setting; this.filters = filters; + if (filters.length == 0) { + throw new IllegalArgumentException("Must use one or more filter patterns (zero specified)"); + } } public boolean isEnabled() { return setting == null || setting.getBoolean(); } + /** + * @return If {@link FilterGroupList} should include this group when searching. + * By default, all filters are included except non enabled settings that require reboot. + */ + public boolean includeInSearch() { + return isEnabled() || !setting.rebootApp; + } + + @NonNull + @Override + public String toString() { + return getClass().getSimpleName() + ": " + (setting == null ? "(null setting)" : setting); + } + public abstract FilterGroupResult check(final T stack); } class StringFilterGroup extends FilterGroup { - /** - * {@link FilterGroup#FilterGroup(SettingsEnum, Object[])} - */ public StringFilterGroup(final SettingsEnum setting, final String... filters) { super(setting, filters); } @Override public FilterGroupResult check(final String string) { - return new FilterGroupResult(setting, string != null && ReVancedUtils.containsAny(string, filters)); + return new FilterGroupResult(setting, + (setting == null || setting.getBoolean()) && ReVancedUtils.containsAny(string, filters)); } } final class CustomFilterGroup extends StringFilterGroup { - /** - * {@link FilterGroup#FilterGroup(SettingsEnum, Object[])} - */ public CustomFilterGroup(final SettingsEnum setting, final SettingsEnum filter) { super(setting, filter.getString().split(",")); } } +/** + * If you have more than 1 filter patterns, then all instances of + * this class should filtered using {@link ByteArrayFilterGroupList#check(byte[])}, + * which uses a prefix tree to give better performance. + */ class ByteArrayFilterGroup extends FilterGroup { + + private int[][] failurePatterns; + // Modified implementation from https://stackoverflow.com/a/1507813 - private int indexOf(final byte[] data, final byte[] pattern) { - if (data.length == 0) - return -1; + private static int indexOf(final byte[] data, final byte[] pattern, final int[] failure) { + // Finds the first occurrence of the pattern in the byte array using + // KMP matching algorithm. + int patternLength = pattern.length; + for (int i = 0, j = 0, dataLength = data.length; i < dataLength; i++) { + while (j > 0 && pattern[j] != data[i]) { + j = failure[j - 1]; + } + if (pattern[j] == data[i]) { + j++; + } + if (j == patternLength) { + return i - patternLength + 1; + } + } + return -1; + } + + private static int[] createFailurePattern(byte[] pattern) { // Computes the failure function using a boot-strapping process, // where the pattern is matched against itself. - final int[] failure = new int[pattern.length]; + final int patternLength = pattern.length; + final int[] failure = new int[patternLength]; - int j = 0; - for (int i = 1; i < pattern.length; i++) { + for (int i = 1, j = 0; i < patternLength; i++) { while (j > 0 && pattern[j] != pattern[i]) { j = failure[j - 1]; } @@ -101,54 +136,43 @@ class ByteArrayFilterGroup extends FilterGroup { } failure[i] = j; } - - // Finds the first occurrence of the pattern in the byte array using - // KMP matching algorithm. - - j = 0; - - for (int i = 0; i < data.length; i++) { - while (j > 0 && pattern[j] != data[i]) { - j = failure[j - 1]; - } - if (pattern[j] == data[i]) { - j++; - } - if (j == pattern.length) { - return i - pattern.length + 1; - } - } - return -1; + return failure; } - /** - * {@link FilterGroup#FilterGroup(SettingsEnum, Object[])} - */ - public ByteArrayFilterGroup(final SettingsEnum setting, final byte[]... filters) { + public ByteArrayFilterGroup(SettingsEnum setting, byte[]... filters) { super(setting, filters); } + private void buildFailurePatterns() { + LogHelper.printDebug(() -> "Building failure array for: " + this); + failurePatterns = new int[filters.length][]; + int i = 0; + for (byte[] pattern : filters) { + failurePatterns[i++] = createFailurePattern(pattern); + } + } + @Override public FilterGroupResult check(final byte[] bytes) { var matched = false; - for (byte[] filter : filters) { - if (indexOf(bytes, filter) == -1) - continue; - - matched = true; - break; + if (isEnabled()) { + if (failurePatterns == null) { + buildFailurePatterns(); // Lazy load. + } + for (int i = 0, length = filters.length; i < length; i++) { + if (indexOf(bytes, filters[i], failurePatterns[i]) >= 0) { + matched = true; + break; + } + } } - - final var filtered = matched; - return new FilterGroupResult(setting, filtered); + return new FilterGroupResult(setting, matched); } } + final class ByteArrayAsStringFilterGroup extends ByteArrayFilterGroup { - /** - * {@link ByteArrayFilterGroup#ByteArrayFilterGroup(SettingsEnum, byte[]...)} - */ @RequiresApi(api = Build.VERSION_CODES.N) public ByteArrayAsStringFilterGroup(SettingsEnum setting, String... filters) { super(setting, Arrays.stream(filters).map(String::getBytes).toArray(byte[][]::new)); @@ -156,11 +180,38 @@ final class ByteArrayAsStringFilterGroup extends ByteArrayFilterGroup { } abstract class FilterGroupList> implements Iterable { - private final ArrayList filterGroups = new ArrayList<>(); + + private final List filterGroups = new ArrayList<>(); + /** + * Search graph. Created only if needed. + */ + private TrieSearch search; @SafeVarargs - protected final void addAll(final T... filterGroups) { - this.filterGroups.addAll(Arrays.asList(filterGroups)); + protected final void addAll(final T... groups) { + filterGroups.addAll(Arrays.asList(groups)); + search = null; // Rebuild, if already created. + } + + protected final void buildSearch() { + LogHelper.printDebug(() -> "Creating prefix search tree for: " + this); + search = createSearchGraph(); + for (T group : filterGroups) { + if (!group.includeInSearch()) { + continue; + } + for (V pattern : group.filters) { + search.addPattern(pattern, (textSearched, matchedStartIndex, callbackParameter) -> { + if (group.isEnabled()) { + FilterGroup.FilterGroupResult result = (FilterGroup.FilterGroupResult) callbackParameter; + result.setting = group.setting; + result.filtered = true; + return true; + } + return false; + }); + } + } } @NonNull @@ -182,94 +233,207 @@ abstract class FilterGroupList> implements Iterable< return filterGroups.spliterator(); } - protected boolean contains(final V stack) { - for (T filterGroup : this) { - if (!filterGroup.isEnabled()) - continue; - - var result = filterGroup.check(stack); - if (result.isFiltered()) { - return true; - } + protected FilterGroup.FilterGroupResult check(V stack) { + if (search == null) { + buildSearch(); // Lazy load. } - - return false; + FilterGroup.FilterGroupResult result = new FilterGroup.FilterGroupResult(null, false); + search.matches(stack, result); + return result; } + + protected abstract TrieSearch createSearchGraph(); } final class StringFilterGroupList extends FilterGroupList { + protected StringTrieSearch createSearchGraph() { + return new StringTrieSearch(); + } } +/** + * If searching for a single byte pattern, then it is slightly better to use + * {@link ByteArrayFilterGroup#check(byte[])} as it uses KMP which is faster + * than a prefix tree to search for only 1 pattern. + */ final class ByteArrayFilterGroupList extends FilterGroupList { + protected ByteTrieSearch createSearchGraph() { + return new ByteTrieSearch(); + } } abstract class Filter { - final protected StringFilterGroupList pathFilterGroups = new StringFilterGroupList(); - final protected StringFilterGroupList identifierFilterGroups = new StringFilterGroupList(); - final protected ByteArrayFilterGroupList protobufBufferFilterGroups = new ByteArrayFilterGroupList(); + /** + * All group filters must be set before the constructor call completes. + * Otherwise {@link #isFiltered(String, String, byte[], FilterGroupList, FilterGroup, int)} + * will never be called for any matches. + */ + + protected final StringFilterGroupList pathFilterGroups = new StringFilterGroupList(); + protected final StringFilterGroupList identifierFilterGroups = new StringFilterGroupList(); + /** + * A collection of {@link ByteArrayFilterGroup} that are always searched for (no matter what). + * + * If possible, avoid adding values to this list and instead use a path or identifier filter + * for the item you are looking for. Then inside + * {@link #isFiltered(String, String, byte[], FilterGroupList, FilterGroup, int)}, + * the buffer can then be searched using using a different + * {@link ByteArrayFilterGroupList} or a {@link ByteArrayFilterGroup}. + * This way, the expensive buffer searching only occurs if the cheap and fast path/identifier is already found. + */ + protected final ByteArrayFilterGroupList protobufBufferFilterGroups = new ByteArrayFilterGroupList(); /** - * Check if the given path, identifier or protobuf buffer is filtered by any - * {@link FilterGroup}. Method is called off the main thread. + * Called after an enabled filter has been matched. + * Default implementation is to always filter the matched item. + * Subclasses can perform additional or different checks if needed. * - * @return True if filtered, false otherwise. + * Method is called off the main thread. + * + * @param matchedList The list the group filter belongs to. + * @param matchedGroup The actual filter that matched. + * @param matchedIndex Matched index of string/array. + * @return True if the litho item should be filtered out. */ - boolean isFiltered(final String path, final String identifier, final byte[] protobufBufferArray) { - if (pathFilterGroups.contains(path)) { - LogHelper.printDebug(() -> String.format("Filtered path: %s", path)); - return true; + @SuppressWarnings("rawtypes") + boolean isFiltered(String path, @Nullable String identifier, byte[] protobufBufferArray, + FilterGroupList matchedList, FilterGroup matchedGroup, int matchedIndex) { + if (SettingsEnum.DEBUG.getBoolean()) { + if (pathFilterGroups == matchedList) { + LogHelper.printDebug(() -> getClass().getSimpleName() + " Filtered path: " + path); + } else if (identifierFilterGroups == matchedList) { + LogHelper.printDebug(() -> getClass().getSimpleName() + " Filtered identifier: " + identifier); + } else if (protobufBufferFilterGroups == matchedList) { + LogHelper.printDebug(() -> getClass().getSimpleName() + " Filtered from protobuf-buffer"); + } } - - if (identifierFilterGroups.contains(identifier)) { - LogHelper.printDebug(() -> String.format("Filtered identifier: %s", identifier)); - return true; - } - - if (protobufBufferFilterGroups.contains(protobufBufferArray)) { - LogHelper.printDebug(() -> "Filtered from protobuf-buffer"); - return true; - } - - return false; + return true; } } @RequiresApi(api = Build.VERSION_CODES.N) @SuppressWarnings("unused") public final class LithoFilterPatch { + /** + * Simple wrapper to pass the litho parameters through the prefix search. + */ + private static final class LithoFilterParameters { + final String path; + final String identifier; + final byte[] protoBuffer; + + LithoFilterParameters(StringBuilder lithoPath, String lithoIdentifier, ByteBuffer protoBuffer) { + this.path = lithoPath.toString(); + this.identifier = lithoIdentifier; + this.protoBuffer = protoBuffer.array(); + } + + @NonNull + @Override + public String toString() { + // Estimate the percentage of the buffer that are Strings. + StringBuilder builder = new StringBuilder(protoBuffer.length / 2); + builder.append( "ID: "); + builder.append(identifier); + builder.append(" Path: "); + builder.append(path); + // TODO: allow turning on/off buffer logging with a debug setting? + builder.append(" BufferStrings: "); + findAsciiStrings(builder, protoBuffer); + + return builder.toString(); + } + + /** + * Search through a byte array for all ASCII strings. + */ + private static void findAsciiStrings(StringBuilder builder, byte[] buffer) { + // Valid ASCII values (ignore control characters). + final int minimumAscii = 32; // 32 = space character + final int maximumAscii = 126; // 127 = delete character + final int minimumAsciiStringLength = 4; // Minimum length of an ASCII string to include. + String delimitingCharacter = "❙"; // Non ascii character, to allow easier log filtering. + + final int length = buffer.length; + int start = 0; + int end = 0; + while (end < length) { + int value = buffer[end]; + if (value < minimumAscii || value > maximumAscii || end == length - 1) { + if (end - start >= minimumAsciiStringLength) { + builder.append(new String(buffer, start, end - start)); + builder.append(delimitingCharacter); + } + start = end + 1; + } + end++; + } + } + } + private static final Filter[] filters = new Filter[] { new DummyFilter() // Replaced by patch. }; + private static final StringTrieSearch pathSearchTree = new StringTrieSearch(); + private static final StringTrieSearch identifierSearchTree = new StringTrieSearch(); + private static final ByteTrieSearch protoSearchTree = new ByteTrieSearch(); + + static { + for (Filter filter : filters) { + filterGroupLists(pathSearchTree, filter, filter.pathFilterGroups); + filterGroupLists(identifierSearchTree, filter, filter.identifierFilterGroups); + filterGroupLists(protoSearchTree, filter, filter.protobufBufferFilterGroups); + } + + LogHelper.printDebug(() -> "Using: " + + pathSearchTree.numberOfPatterns() + " path filters" + + " (" + pathSearchTree.getEstimatedMemorySize() + " KB), " + + identifierSearchTree.numberOfPatterns() + " identifier filters" + + " (" + identifierSearchTree.getEstimatedMemorySize() + " KB), " + + protoSearchTree.numberOfPatterns() + " buffer filters" + + " (" + protoSearchTree.getEstimatedMemorySize() + " KB)"); + } + + private static void filterGroupLists(TrieSearch pathSearchTree, + Filter filter, FilterGroupList> list) { + for (FilterGroup group : list) { + if (!group.includeInSearch()) { + continue; + } + for (T pattern : group.filters) { + pathSearchTree.addPattern(pattern, (textSearched, matchedStartIndex, callbackParameter) -> { + if (!group.isEnabled()) return false; + LithoFilterParameters parameters = (LithoFilterParameters) callbackParameter; + return filter.isFiltered(parameters.path, parameters.identifier, parameters.protoBuffer, + list, group, matchedStartIndex); + } + ); + } + } + } + /** * Injection point. Called off the main thread. */ @SuppressWarnings("unused") - public static boolean filter(final StringBuilder pathBuilder, final String identifier, - final ByteBuffer protobufBuffer) { - // TODO: Maybe this can be moved to the Filter class, to prevent unnecessary - // string creation - // because some filters might not need the path. - var path = pathBuilder.toString(); + public static boolean filter(@NonNull StringBuilder pathBuilder, @Nullable String lithoIdentifier, + @NonNull ByteBuffer protobufBuffer) { + try { + // It is assumed that protobufBuffer is empty as well in this case. + if (pathBuilder.length() == 0) + return false; - // It is assumed that protobufBuffer is empty as well in this case. - if (path.isEmpty()) - return false; + LithoFilterParameters parameter = new LithoFilterParameters(pathBuilder, lithoIdentifier, protobufBuffer); + LogHelper.printDebug(() -> "Searching " + parameter); - LogHelper.printDebug(() -> String.format( - "Searching (ID: %s, Buffer-size: %s): %s", - identifier, protobufBuffer.remaining(), path)); - - var protobufBufferArray = protobufBuffer.array(); - - for (var filter : filters) { - var filtered = filter.isFiltered(path, identifier, protobufBufferArray); - - LogHelper.printDebug( - () -> String.format("%s (ID: %s): %s", filtered ? "Filtered" : "Unfiltered", identifier, path)); - - if (filtered) - return true; + if (pathSearchTree.matches(parameter.path, parameter)) return true; + if (parameter.identifier != null) { + if (identifierSearchTree.matches(parameter.identifier, parameter)) return true; + } + if (protoSearchTree.matches(parameter.protoBuffer, parameter)) return true; + } catch (Exception ex) { + LogHelper.printException(() -> "Litho filter failure", ex); } return false; diff --git a/app/src/main/java/app/revanced/integrations/patches/components/PlaybackSpeedMenuFilterPatch.java b/app/src/main/java/app/revanced/integrations/patches/components/PlaybackSpeedMenuFilterPatch.java index 66b229f4..ab2e63b4 100644 --- a/app/src/main/java/app/revanced/integrations/patches/components/PlaybackSpeedMenuFilterPatch.java +++ b/app/src/main/java/app/revanced/integrations/patches/components/PlaybackSpeedMenuFilterPatch.java @@ -1,5 +1,7 @@ package app.revanced.integrations.patches.components; +import androidx.annotation.Nullable; + // Abuse LithoFilter for CustomPlaybackSpeedPatch. public final class PlaybackSpeedMenuFilterPatch extends Filter { // Must be volatile or synchronized, as litho filtering runs off main thread and this field is then access from the main thread. @@ -13,8 +15,9 @@ public final class PlaybackSpeedMenuFilterPatch extends Filter { } @Override - boolean isFiltered(final String path, final String identifier, final byte[] protobufBufferArray) { - isPlaybackSpeedMenuVisible = super.isFiltered(path, identifier, protobufBufferArray); + boolean isFiltered(String path, @Nullable String identifier, byte[] protobufBufferArray, + FilterGroupList matchedList, FilterGroup matchedGroup, int matchedIndex) { + isPlaybackSpeedMenuVisible = true; return false; } diff --git a/app/src/main/java/app/revanced/integrations/patches/components/PlayerFlyoutMenuItemsFilter.java b/app/src/main/java/app/revanced/integrations/patches/components/PlayerFlyoutMenuItemsFilter.java index 3d33420c..3f435e08 100644 --- a/app/src/main/java/app/revanced/integrations/patches/components/PlayerFlyoutMenuItemsFilter.java +++ b/app/src/main/java/app/revanced/integrations/patches/components/PlayerFlyoutMenuItemsFilter.java @@ -2,14 +2,22 @@ package app.revanced.integrations.patches.components; import android.os.Build; +import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import app.revanced.integrations.settings.SettingsEnum; public class PlayerFlyoutMenuItemsFilter extends Filter { + + // Search the buffer only if the flyout menu identifier is found. + // Handle the searching in this class instead of adding to the global filter group (which searches all the time) + private final ByteArrayFilterGroupList flyoutFilterGroupList = new ByteArrayFilterGroupList(); + @RequiresApi(api = Build.VERSION_CODES.N) public PlayerFlyoutMenuItemsFilter() { - protobufBufferFilterGroups.addAll( + identifierFilterGroups.addAll(new StringFilterGroup(null, "overflow_menu_item.eml|")); + + flyoutFilterGroupList.addAll( new ByteArrayAsStringFilterGroup( SettingsEnum.HIDE_QUALITY_MENU, "yt_outline_gear" @@ -54,10 +62,13 @@ public class PlayerFlyoutMenuItemsFilter extends Filter { } @Override - boolean isFiltered(String path, String identifier, byte[] _protobufBufferArray) { - if (identifier != null && identifier.startsWith("overflow_menu_item.eml|")) - return super.isFiltered(path, identifier, _protobufBufferArray); - + boolean isFiltered(String path, @Nullable String identifier, byte[] protobufBufferArray, + FilterGroupList matchedList, FilterGroup matchedGroup, int matchedIndex) { + // Only 1 group is added to the parent class, so the matched group must be the overflow menu. + if (matchedIndex == 0 && flyoutFilterGroupList.check(protobufBufferArray).isFiltered()) { + // Super class handles logging. + return super.isFiltered(path, identifier, protobufBufferArray, matchedList, matchedGroup, matchedIndex); + } return false; } } diff --git a/app/src/main/java/app/revanced/integrations/patches/components/ShortsFilter.java b/app/src/main/java/app/revanced/integrations/patches/components/ShortsFilter.java index ec1ffe46..c63a7614 100644 --- a/app/src/main/java/app/revanced/integrations/patches/components/ShortsFilter.java +++ b/app/src/main/java/app/revanced/integrations/patches/components/ShortsFilter.java @@ -1,22 +1,40 @@ package app.revanced.integrations.patches.components; -import android.view.View; -import app.revanced.integrations.settings.SettingsEnum; -import com.google.android.libraries.youtube.rendering.ui.pivotbar.PivotBar; - import static app.revanced.integrations.utils.ReVancedUtils.hideViewBy1dpUnderCondition; import static app.revanced.integrations.utils.ReVancedUtils.hideViewUnderCondition; +import android.view.View; + +import androidx.annotation.Nullable; + +import com.google.android.libraries.youtube.rendering.ui.pivotbar.PivotBar; + +import app.revanced.integrations.settings.SettingsEnum; + public final class ShortsFilter extends Filter { - // Set by patch. - public static PivotBar pivotBar; - final StringFilterGroupList shortsFilterGroup = new StringFilterGroupList(); - private final StringFilterGroup reelChannelBar = new StringFilterGroup( - null, - "reel_channel_bar" - ); + private static final String REEL_CHANNEL_BAR_PATH = "reel_channel_bar"; + public static PivotBar pivotBar; // Set by patch. + + private final StringFilterGroup channelBar; + private final StringFilterGroup soundButton; + private final StringFilterGroup infoPanel; public ShortsFilter() { + channelBar = new StringFilterGroup( + SettingsEnum.HIDE_SHORTS_CHANNEL_BAR, + REEL_CHANNEL_BAR_PATH + ); + + soundButton = new StringFilterGroup( + SettingsEnum.HIDE_SHORTS_SOUND_BUTTON, + "reel_pivot_button" + ); + + infoPanel = new StringFilterGroup( + SettingsEnum.HIDE_SHORTS_INFO_PANEL, + "shorts_info_panel_overview" + ); + final var thanksButton = new StringFilterGroup( SettingsEnum.HIDE_SHORTS_THANKS_BUTTON, "suggested_action" @@ -32,21 +50,6 @@ public final class ShortsFilter extends Filter { "sponsor_button" ); - final var soundButton = new StringFilterGroup( - SettingsEnum.HIDE_SHORTS_SOUND_BUTTON, - "reel_pivot_button" - ); - - final var infoPanel = new StringFilterGroup( - SettingsEnum.HIDE_SHORTS_INFO_PANEL, - "shorts_info_panel_overview" - ); - - final var channelBar = new StringFilterGroup( - SettingsEnum.HIDE_SHORTS_CHANNEL_BAR, - "reel_channel_bar" - ); - final var shorts = new StringFilterGroup( SettingsEnum.HIDE_SHORTS, "shorts_shelf", @@ -55,22 +58,21 @@ public final class ShortsFilter extends Filter { "shorts_video_cell" ); - shortsFilterGroup.addAll(soundButton, infoPanel); - pathFilterGroups.addAll(joinButton, subscribeButton, channelBar); + pathFilterGroups.addAll(joinButton, subscribeButton, channelBar, soundButton, infoPanel); identifierFilterGroups.addAll(shorts, thanksButton); } @Override - boolean isFiltered(final String path, final String identifier, - final byte[] protobufBufferArray) { + boolean isFiltered(String path, @Nullable String identifier, byte[] protobufBufferArray, + FilterGroupList matchedList, FilterGroup matchedGroup, int matchedIndex) { + if (matchedGroup == soundButton || matchedGroup == infoPanel || matchedGroup == channelBar) return true; // Filter the path only when reelChannelBar is visible. - if (reelChannelBar.check(path).isFiltered()) - if (this.pathFilterGroups.contains(path)) return true; + if (pathFilterGroups == matchedList) { + return path.contains(REEL_CHANNEL_BAR_PATH); + } - if (shortsFilterGroup.contains(path)) return true; - - return this.identifierFilterGroups.contains(identifier); + return identifierFilterGroups == matchedList; } public static void hideShortsShelf(final View shortsShelfView) { diff --git a/app/src/main/java/app/revanced/integrations/patches/components/VideoQualityMenuFilterPatch.java b/app/src/main/java/app/revanced/integrations/patches/components/VideoQualityMenuFilterPatch.java index a500c7ba..fb94de05 100644 --- a/app/src/main/java/app/revanced/integrations/patches/components/VideoQualityMenuFilterPatch.java +++ b/app/src/main/java/app/revanced/integrations/patches/components/VideoQualityMenuFilterPatch.java @@ -1,5 +1,7 @@ package app.revanced.integrations.patches.components; +import androidx.annotation.Nullable; + import app.revanced.integrations.settings.SettingsEnum; // Abuse LithoFilter for OldVideoQualityMenuPatch. @@ -15,8 +17,9 @@ public final class VideoQualityMenuFilterPatch extends Filter { } @Override - boolean isFiltered(final String path, final String identifier, final byte[] protobufBufferArray) { - isVideoQualityMenuVisible = super.isFiltered(path, identifier, protobufBufferArray); + boolean isFiltered(String path, @Nullable String identifier, byte[] protobufBufferArray, + FilterGroupList matchedList, FilterGroup matchedGroup, int matchedIndex) { + isVideoQualityMenuVisible = true; return false; } diff --git a/app/src/main/java/app/revanced/integrations/patches/playback/quality/OldVideoQualityMenuPatch.java b/app/src/main/java/app/revanced/integrations/patches/playback/quality/OldVideoQualityMenuPatch.java index 97582040..70defb4c 100644 --- a/app/src/main/java/app/revanced/integrations/patches/playback/quality/OldVideoQualityMenuPatch.java +++ b/app/src/main/java/app/revanced/integrations/patches/playback/quality/OldVideoQualityMenuPatch.java @@ -26,7 +26,8 @@ public final class OldVideoQualityMenuPatch { // The quality menu is a RecyclerView with 3 children. The third child is the "Advanced" quality menu. addRecyclerListener(linearLayout, 3, 2, recyclerView -> { // Check if the current view is the quality menu. - if (VideoQualityMenuFilterPatch.isVideoQualityMenuVisible) {// Hide the video quality menu. + if (VideoQualityMenuFilterPatch.isVideoQualityMenuVisible) { + VideoQualityMenuFilterPatch.isVideoQualityMenuVisible = false; linearLayout.setVisibility(View.GONE); // Click the "Advanced" quality menu to show the "old" quality menu. diff --git a/app/src/main/java/app/revanced/integrations/patches/playback/speed/CustomPlaybackSpeedPatch.java b/app/src/main/java/app/revanced/integrations/patches/playback/speed/CustomPlaybackSpeedPatch.java index 631ad943..a65a2530 100644 --- a/app/src/main/java/app/revanced/integrations/patches/playback/speed/CustomPlaybackSpeedPatch.java +++ b/app/src/main/java/app/revanced/integrations/patches/playback/speed/CustomPlaybackSpeedPatch.java @@ -1,22 +1,19 @@ package app.revanced.integrations.patches.playback.speed; -import static app.revanced.integrations.patches.playback.quality.OldVideoQualityMenuPatch.addRecyclerListener; - import android.preference.ListPreference; import android.view.View; import android.view.ViewGroup; import android.widget.LinearLayout; - import androidx.annotation.NonNull; - -import com.facebook.litho.ComponentHost; - -import java.util.Arrays; - import app.revanced.integrations.patches.components.PlaybackSpeedMenuFilterPatch; import app.revanced.integrations.settings.SettingsEnum; import app.revanced.integrations.utils.LogHelper; import app.revanced.integrations.utils.ReVancedUtils; +import com.facebook.litho.ComponentHost; + +import java.util.Arrays; + +import static app.revanced.integrations.patches.playback.quality.OldVideoQualityMenuPatch.addRecyclerListener; public class CustomPlaybackSpeedPatch { /** @@ -110,23 +107,24 @@ public class CustomPlaybackSpeedPatch { } /* - * To reduce copy paste between two similar code paths. + * To reduce copy and paste between two similar code paths. */ public static void onFlyoutMenuCreate(final LinearLayout linearLayout) { // The playback rate menu is a RecyclerView with 2 children. The third child is the "Advanced" quality menu. addRecyclerListener(linearLayout, 2, 1, recyclerView -> { - if (PlaybackSpeedMenuFilterPatch.isPlaybackSpeedMenuVisible && - recyclerView.getChildCount() == 1 && - recyclerView.getChildAt(0) instanceof ComponentHost - ) { - linearLayout.setVisibility(View.GONE); + if (PlaybackSpeedMenuFilterPatch.isPlaybackSpeedMenuVisible) { + PlaybackSpeedMenuFilterPatch.isPlaybackSpeedMenuVisible = false; - // Close the new Playback speed menu and instead show the old one. - showOldPlaybackSpeedMenu(); + if (recyclerView.getChildCount() == 1 && recyclerView.getChildAt(0) instanceof ComponentHost) { + linearLayout.setVisibility(View.GONE); - // DismissView [R.id.touch_outside] is the 1st ChildView of the 3rd ParentView. - ((ViewGroup) linearLayout.getParent().getParent().getParent()) - .getChildAt(0).performClick(); + // Close the new Playback speed menu and instead show the old one. + showOldPlaybackSpeedMenu(); + + // DismissView [R.id.touch_outside] is the 1st ChildView of the 3rd ParentView. + ((ViewGroup) linearLayout.getParent().getParent().getParent()) + .getChildAt(0).performClick(); + } } }); } diff --git a/app/src/main/java/app/revanced/integrations/utils/ByteTrieSearch.java b/app/src/main/java/app/revanced/integrations/utils/ByteTrieSearch.java new file mode 100644 index 00000000..e83a921c --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/utils/ByteTrieSearch.java @@ -0,0 +1,38 @@ +package app.revanced.integrations.utils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Objects; + +public final class ByteTrieSearch extends TrieSearch { + + private static final class ByteTrieNode extends TrieNode { + TrieNode createNode() { + return new ByteTrieNode(); + } + char getCharValue(byte[] text, int index) { + return (char) text[index]; + } + } + + public ByteTrieSearch() { + super(new ByteTrieNode()); + } + + @Override + public void addPattern(@NonNull byte[] pattern) { + super.addPattern(pattern, pattern.length, null); + } + + @Override + public void addPattern(@NonNull byte[] pattern, @NonNull TriePatternMatchedCallback callback) { + super.addPattern(pattern, pattern.length, Objects.requireNonNull(callback)); + } + + @Override + public boolean matches(@NonNull byte[] textToSearch, @Nullable Object callbackParameter) { + return super.matches(textToSearch, textToSearch.length, callbackParameter); + } + +} diff --git a/app/src/main/java/app/revanced/integrations/utils/StringTrieSearch.java b/app/src/main/java/app/revanced/integrations/utils/StringTrieSearch.java new file mode 100644 index 00000000..7afafc60 --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/utils/StringTrieSearch.java @@ -0,0 +1,40 @@ +package app.revanced.integrations.utils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Objects; + +/** + * Text pattern searching using a prefix tree (trie). + */ +public final class StringTrieSearch extends TrieSearch { + + private static final class StringTrieNode extends TrieNode { + TrieNode createNode() { + return new StringTrieNode(); + } + char getCharValue(String text, int index) { + return text.charAt(index); + } + } + + public StringTrieSearch() { + super(new StringTrieNode()); + } + + @Override + public void addPattern(@NonNull String pattern) { + super.addPattern(pattern, pattern.length(), null); + } + + @Override + public void addPattern(@NonNull String pattern, @NonNull TriePatternMatchedCallback callback) { + super.addPattern(pattern, pattern.length(), Objects.requireNonNull(callback)); + } + + @Override + public boolean matches(@NonNull String textToSearch, @Nullable Object callbackParameter) { + return super.matches(textToSearch, textToSearch.length(), callbackParameter); + } +} diff --git a/app/src/main/java/app/revanced/integrations/utils/TrieSearch.java b/app/src/main/java/app/revanced/integrations/utils/TrieSearch.java new file mode 100644 index 00000000..9d2db167 --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/utils/TrieSearch.java @@ -0,0 +1,305 @@ +package app.revanced.integrations.utils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Searches for a group of different patterns using a trie (prefix tree). + * Can significantly speed up searching for multiple patterns. + * + * Currently only supports ASCII non-control characters (letters/numbers/symbols). + * But could be modified to also support UTF-8 unicode. + */ +public abstract class TrieSearch { + + public interface TriePatternMatchedCallback { + /** + * Called when a pattern is matched. + * + * @param textSearched Text that was searched. + * @param matchedStartIndex Start index of the search text, where the pattern was matched. + * @param callbackParameter Optional parameter passed into {@link TrieSearch#matches(Object, Object)}. + * @return True, if the search should stop here. + * If false, searching will continue to look for other matches. + */ + boolean patternMatched(T textSearched, int matchedStartIndex, Object callbackParameter); + } + + /** + * Represents a compressed tree path for a single pattern that shares no sibling nodes. + * + * For example, if a tree contains the patterns: "foobar", "football", "feet", + * it would contain 3 compressed paths of: "bar", "tball", "eet". + * + * And the tree would contain children arrays only for the first level containing 'f', + * the second level containing 'o', + * and the third level containing 'o'. + * + * This is done to reduce memory usage, which can be substantial if many long patterns are used. + */ + private static final class TrieCompressedPath { + final T pattern; + final int patternLength; + final int patternStartIndex; + final TriePatternMatchedCallback callback; + + TrieCompressedPath(T pattern, int patternLength, int patternStartIndex, TriePatternMatchedCallback callback) { + this.pattern = pattern; + this.patternLength = patternLength; + this.patternStartIndex = patternStartIndex; + this.callback = callback; + } + boolean matches(TrieNode enclosingNode, // Used only for the get character method. + T searchText, int searchTextLength, int searchTextIndex, Object callbackParameter) { + if (searchTextLength - searchTextIndex < patternLength - patternStartIndex) { + return false; // Remaining search text is shorter than the remaining leaf pattern and they cannot match. + } + for (int i = searchTextIndex, j = patternStartIndex; j < patternLength; i++, j++) { + if (enclosingNode.getCharValue(searchText, i) != enclosingNode.getCharValue(pattern, j)) { + return false; + } + } + return callback == null + || callback.patternMatched(searchText, searchTextIndex - patternStartIndex, callbackParameter); + } + } + + static abstract class TrieNode { + // Support only ASCII letters/numbers/symbols and filter out all control characters. + private static final char MIN_VALID_CHAR = 32; // Space character. + private static final char MAX_VALID_CHAR = 126; // 127 = delete character. + private static final int NUMBER_OF_CHILDREN = MAX_VALID_CHAR - MIN_VALID_CHAR + 1; + + private static boolean isInvalidRange(char character) { + return character < MIN_VALID_CHAR || character > MAX_VALID_CHAR; + } + + /** + * A compressed graph path that represents the remaining pattern characters of a single child node. + * + * If present then child array is always null, although callbacks for other + * end of patterns can also exist on this same node. + */ + @Nullable + private TrieCompressedPath leaf; + + /** + * All child nodes. Only present if no compressed leaf exist. + */ + @Nullable + private TrieNode[] children; + + /** + * Callbacks for all patterns that end at this node. + */ + @Nullable + private List> endOfPatternCallback; + + /** + * @param pattern Pattern to add. + * @param patternLength Length of the pattern. + * @param patternIndex Current recursive index of the pattern. + * @param callback Callback, where a value of NULL indicates to always accept a pattern match. + */ + private void addPattern(@NonNull T pattern, int patternLength, int patternIndex, + @Nullable TriePatternMatchedCallback callback) { + if (patternIndex == patternLength) { // Reached the end of the pattern. + if (endOfPatternCallback == null) { + endOfPatternCallback = new ArrayList<>(1); + } + endOfPatternCallback.add(callback); + return; + } + if (leaf != null) { + // Reached end of the graph and a leaf exist. + // Recursively call back into this method and push the existing leaf down 1 level. + if (children != null) throw new IllegalStateException(); + //noinspection unchecked + children = new TrieNode[NUMBER_OF_CHILDREN]; + TrieCompressedPath temp = leaf; + leaf = null; + addPattern(temp.pattern, temp.patternLength, temp.patternStartIndex, temp.callback); + // Continue onward and add the parameter pattern. + } else if (children == null) { + leaf = new TrieCompressedPath<>(pattern, patternLength, patternIndex, callback); + return; + } + char character = getCharValue(pattern, patternIndex); + if (isInvalidRange(character)) { + throw new IllegalArgumentException("invalid character at index " + patternIndex + ": " + pattern); + } + character -= MIN_VALID_CHAR; // Adjust to the array range. + TrieNode child = children[character]; + if (child == null) { + child = createNode(); + children[character] = child; + } + child.addPattern(pattern, patternLength, patternIndex + 1, callback); + } + + /** + * @param searchText Text to search for patterns in. + * @param searchTextLength Length of the search text. + * @param searchTextIndex Current recursive search text index. Also, the end index of the current pattern match. + * @param currentMatchLength current search depth, and also the length of the current pattern match. + * @return If any pattern matches, and it's associated callback halted the search. + */ + private boolean matches(T searchText, int searchTextLength, int searchTextIndex, int currentMatchLength, + Object callbackParameter) { + if (leaf != null && leaf.matches(this, + searchText, searchTextLength, searchTextIndex, callbackParameter)) { + return true; // Leaf exists and it matched the search text. + } + if (endOfPatternCallback != null) { + final int matchStartIndex = searchTextIndex - currentMatchLength; + for (@Nullable TriePatternMatchedCallback callback : endOfPatternCallback) { + if (callback == null) { + return true; // No callback and all matches are valid. + } + if (callback.patternMatched(searchText, matchStartIndex, callbackParameter)) { + return true; // Callback confirmed the match. + } + } + } + if (children == null) { + return false; // Reached a graph end point and there's no further patterns to search. + } + + if (searchTextIndex == searchTextLength) { + return false; // Reached end of the search text and found no matches. + } + + char character = getCharValue(searchText, searchTextIndex); + if (isInvalidRange(character)) { + return false; // Not an ASCII letter/number/symbol. + } + character -= MIN_VALID_CHAR; // Adjust to the array range. + TrieNode child = children[character]; + if (child == null) { + return false; + } + return child.matches(searchText, searchTextLength, searchTextIndex + 1, + currentMatchLength + 1, callbackParameter); + } + + /** + * Gives an approximate memory usage. + * + * @return Estimated number of memory pointers used, starting from this node and including all children. + */ + private int estimatedNumberOfPointersUsed() { + int numberOfPointers = 3; // Number of fields in this class. + if (leaf != null) { + numberOfPointers += 4; // Number of fields in leaf node. + } + if (endOfPatternCallback != null) { + numberOfPointers += endOfPatternCallback.size(); + } + if (children != null) { + numberOfPointers += NUMBER_OF_CHILDREN; + for (TrieNode child : children) { + if (child != null) { + numberOfPointers += child.estimatedNumberOfPointersUsed(); + } + } + } + return numberOfPointers; + } + + abstract TrieNode createNode(); + abstract char getCharValue(T text, int index); + } + + /** + * Root node, and it's children represent the first pattern characters. + */ + private final TrieNode root; + + /** + * Patterns to match. + */ + private final List patterns = new ArrayList<>(); + + TrieSearch(@NonNull TrieNode root) { + this.root = Objects.requireNonNull(root); + } + + @SafeVarargs + public final void addPatterns(@NonNull T... patterns) { + for (T pattern : patterns) { + addPattern(pattern); + } + } + + void addPattern(@NonNull T pattern, int patternLength, @Nullable TriePatternMatchedCallback callback) { + if (patternLength == 0) return; // Nothing to match + + patterns.add(pattern); + root.addPattern(pattern, patternLength, 0, callback); + } + + boolean matches(@NonNull T textToSearch, int textToSearchLength, @Nullable Object callbackParameter) { + if (patterns.size() == 0) { + return false; // No patterns were added. + } + for (int i = 0; i < textToSearchLength; i++) { + if (root.matches(textToSearch, textToSearchLength, i, 0, callbackParameter)) return true; + } + return false; + } + + /** + * @return Estimated memory size (in kilobytes) of this instance. + */ + public int getEstimatedMemorySize() { + if (patterns.size() == 0) { + return 0; + } + // Assume the device has less than 32GB of ram (and can use pointer compression), + // or the device is 32-bit. + final int numberOfBytesPerPointer = 4; + return (int) Math.ceil((numberOfBytesPerPointer * root.estimatedNumberOfPointersUsed()) / 1024.0); + } + + public int numberOfPatterns() { + return patterns.size(); + } + + public List getPatterns() { + return Collections.unmodifiableList(patterns); + } + + /** + * Adds a pattern that will always return a positive match if found. + * + * @param pattern Pattern to add. Calling this with a zero length pattern does nothing. + */ + public abstract void addPattern(@NonNull T pattern); + + /** + * @param pattern Pattern to add. Calling this with a zero length pattern does nothing. + * @param callback Callback to determine if searching should halt when a match is found. + */ + public abstract void addPattern(@NonNull T pattern, @NonNull TriePatternMatchedCallback callback); + + /** + * Searches through text, looking for any substring that matches any pattern in this tree. + * + * @param textToSearch Text to search through. + * @param callbackParameter Optional parameter passed to the callbacks. + * @return If any pattern matched, and it's callback halted searching. + */ + public abstract boolean matches(@NonNull T textToSearch, @Nullable Object callbackParameter); + + /** + * Identical to {@link #matches(Object, Object)} but with a null callback parameter. + */ + public final boolean matches(@NonNull T textToSearch) { + return matches(textToSearch, null); + } +}