From 0cbad9820577c476f1f29b6ac77611b38afbb950 Mon Sep 17 00:00:00 2001 From: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com> Date: Wed, 27 Mar 2024 11:26:26 +0400 Subject: [PATCH] feat(YouTube - Hide layout components): Filter home/search results by keywords (#584) Co-authored-by: oSumAtrIX --- .../revanced/integrations/shared/Utils.java | 19 +- .../integrations/youtube/ByteTrieSearch.java | 22 +- .../youtube/StringTrieSearch.java | 18 +- .../integrations/youtube/TrieSearch.java | 56 ++-- .../patches/NavigationButtonsPatch.java | 47 +-- .../patches/components/CustomFilter.java | 20 +- .../components/KeywordContentFilter.java | 284 ++++++++++++++++++ .../patches/components/LithoFilterPatch.java | 3 +- .../youtube/settings/LicenseActivityHook.java | 6 +- .../youtube/settings/Settings.java | 9 + .../youtube/shared/NavigationBar.java | 184 ++++++++++++ .../integrations/youtube/shared/PlayerType.kt | 4 + 12 files changed, 563 insertions(+), 109 deletions(-) create mode 100644 app/src/main/java/app/revanced/integrations/youtube/patches/components/KeywordContentFilter.java create mode 100644 app/src/main/java/app/revanced/integrations/youtube/shared/NavigationBar.java diff --git a/app/src/main/java/app/revanced/integrations/shared/Utils.java b/app/src/main/java/app/revanced/integrations/shared/Utils.java index a7fbfcbc..371100d1 100644 --- a/app/src/main/java/app/revanced/integrations/shared/Utils.java +++ b/app/src/main/java/app/revanced/integrations/shared/Utils.java @@ -196,18 +196,29 @@ public class Utils { return getContext().getResources().getDimension(getResourceIdentifier(resourceIdentifierName, "dimen")); } + public interface MatchFilter { + boolean matches(T object); + } + /** + * @param searchRecursively If children ViewGroups should also be + * recursively searched using depth first search. * @return The first child view that matches the filter. */ @Nullable - public static T getChildView(@NonNull ViewGroup viewGroup, @NonNull MatchFilter filter) { + public static T getChildView(@NonNull ViewGroup viewGroup, boolean searchRecursively, + @NonNull MatchFilter filter) { for (int i = 0, childCount = viewGroup.getChildCount(); i < childCount; i++) { View childAt = viewGroup.getChildAt(i); - //noinspection unchecked if (filter.matches(childAt)) { //noinspection unchecked return (T) childAt; } + // Must do recursive after filter check, in case the filter is looking for a ViewGroup. + if (searchRecursively && childAt instanceof ViewGroup) { + T match = getChildView((ViewGroup) childAt, true, filter); + if (match != null) return match; + } } return null; } @@ -223,10 +234,6 @@ public class Utils { System.exit(0); } - public interface MatchFilter { - boolean matches(T object); - } - public static Context getContext() { if (context == null) { Logger.initializationException(Utils.class, "Context is null, returning null!", null); diff --git a/app/src/main/java/app/revanced/integrations/youtube/ByteTrieSearch.java b/app/src/main/java/app/revanced/integrations/youtube/ByteTrieSearch.java index bc564675..aa2b94b8 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/ByteTrieSearch.java +++ b/app/src/main/java/app/revanced/integrations/youtube/ByteTrieSearch.java @@ -1,5 +1,9 @@ package app.revanced.integrations.youtube; +import androidx.annotation.NonNull; + +import java.nio.charset.StandardCharsets; + public final class ByteTrieSearch extends TrieSearch { private static final class ByteTrieNode extends TrieNode { @@ -24,18 +28,18 @@ public final class ByteTrieSearch extends TrieSearch { } /** - * @return If the pattern is valid to add to this instance. + * Helper method for the common usage of converting Strings to raw UTF-8 bytes. */ - public static boolean isValidPattern(byte[] pattern) { - for (byte b : pattern) { - if (TrieNode.isInvalidRange((char) b)) { - return false; - } + public static byte[][] convertStringsToBytes(String... strings) { + final int length = strings.length; + byte[][] replacement = new byte[length][]; + for (int i = 0; i < length; i++) { + replacement[i] = strings[i].getBytes(StandardCharsets.UTF_8); } - return true; + return replacement; } - public ByteTrieSearch() { - super(new ByteTrieNode()); + public ByteTrieSearch(@NonNull byte[]... patterns) { + super(new ByteTrieNode(), patterns); } } diff --git a/app/src/main/java/app/revanced/integrations/youtube/StringTrieSearch.java b/app/src/main/java/app/revanced/integrations/youtube/StringTrieSearch.java index d2fb7f78..618d9d66 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/StringTrieSearch.java +++ b/app/src/main/java/app/revanced/integrations/youtube/StringTrieSearch.java @@ -1,5 +1,7 @@ package app.revanced.integrations.youtube; +import androidx.annotation.NonNull; + /** * Text pattern searching using a prefix tree (trie). */ @@ -26,19 +28,7 @@ public final class StringTrieSearch extends TrieSearch { } } - /** - * @return If the pattern is valid to add to this instance. - */ - public static boolean isValidPattern(String pattern) { - for (int i = 0, length = pattern.length(); i < length; i++) { - if (TrieNode.isInvalidRange(pattern.charAt(i))) { - return false; - } - } - return true; - } - - public StringTrieSearch() { - super(new StringTrieNode()); + public StringTrieSearch(@NonNull String... patterns) { + super(new StringTrieNode(), patterns); } } diff --git a/app/src/main/java/app/revanced/integrations/youtube/TrieSearch.java b/app/src/main/java/app/revanced/integrations/youtube/TrieSearch.java index 8316597d..1c927cd2 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/TrieSearch.java +++ b/app/src/main/java/app/revanced/integrations/youtube/TrieSearch.java @@ -11,9 +11,6 @@ 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 { @@ -45,14 +42,14 @@ public abstract class TrieSearch { */ private static final class TrieCompressedPath { final T pattern; - final int patternLength; final int patternStartIndex; + final int patternLength; final TriePatternMatchedCallback callback; - TrieCompressedPath(T pattern, int patternLength, int patternStartIndex, TriePatternMatchedCallback callback) { + TrieCompressedPath(T pattern, int patternStartIndex, int patternLength, TriePatternMatchedCallback callback) { this.pattern = pattern; - this.patternLength = patternLength; this.patternStartIndex = patternStartIndex; + this.patternLength = patternLength; this.callback = callback; } boolean matches(TrieNode enclosingNode, // Used only for the get character method. @@ -76,19 +73,10 @@ public abstract class TrieSearch { */ private static final char ROOT_NODE_CHARACTER_VALUE = 0; // ASCII null character. - // 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. - /** * How much to expand the children array when resizing. */ private static final int CHILDREN_ARRAY_INCREASE_SIZE_INCREMENT = 2; - private static final int CHILDREN_ARRAY_MAX_SIZE = MAX_VALID_CHAR - MIN_VALID_CHAR + 1; - - static boolean isInvalidRange(char character) { - return character < MIN_VALID_CHAR || character > MAX_VALID_CHAR; - } /** * Character this node represents. @@ -144,11 +132,11 @@ public abstract class TrieSearch { /** * @param pattern Pattern to add. - * @param patternLength Length of the pattern. * @param patternIndex Current recursive index of the pattern. + * @param patternLength Length 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, + private void addPattern(@NonNull T pattern, int patternIndex, int patternLength, @Nullable TriePatternMatchedCallback callback) { if (patternIndex == patternLength) { // Reached the end of the pattern. if (endOfPatternCallback == null) { @@ -165,16 +153,13 @@ public abstract class TrieSearch { children = new TrieNode[1]; TrieCompressedPath temp = leaf; leaf = null; - addPattern(temp.pattern, temp.patternLength, temp.patternStartIndex, temp.callback); + addPattern(temp.pattern, temp.patternStartIndex, temp.patternLength, temp.callback); // Continue onward and add the parameter pattern. } else if (children == null) { - leaf = new TrieCompressedPath<>(pattern, patternLength, patternIndex, callback); + leaf = new TrieCompressedPath<>(pattern, patternIndex, patternLength, callback); return; } final char character = getCharValue(pattern, patternIndex); - if (isInvalidRange(character)) { - throw new IllegalArgumentException("invalid character at index " + patternIndex + ": " + pattern); - } final int arrayIndex = hashIndexForTableSize(children.length, character); TrieNode child = children[arrayIndex]; if (child == null) { @@ -185,12 +170,11 @@ public abstract class TrieSearch { child = createNode(character); expandChildArray(child); } - child.addPattern(pattern, patternLength, patternIndex + 1, callback); + child.addPattern(pattern, patternIndex + 1, patternLength, callback); } /** * Resizes the children table until all nodes hash to exactly one array index. - * Worse case, this will resize the array to {@link #CHILDREN_ARRAY_MAX_SIZE} elements. */ private void expandChildArray(TrieNode child) { int replacementArraySize = Objects.requireNonNull(children).length; @@ -209,7 +193,6 @@ public abstract class TrieSearch { } } if (collision) { - if (replacementArraySize > CHILDREN_ARRAY_MAX_SIZE) throw new IllegalStateException(); continue; } children = replacement; @@ -232,22 +215,23 @@ public abstract class TrieSearch { /** * This method is static and uses a loop to avoid all recursion. - * This is done for performance since the JVM does not do tail recursion optimization. + * This is done for performance since the JVM does not optimize tail recursion. * * @param startNode Node to start the search from. * @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 searchTextIndex Start index, inclusive. + * @param searchTextEndIndex End index, exclusive. * @return If any pattern matches, and it's associated callback halted the search. */ - private static boolean matches(final TrieNode startNode, final T searchText, final int searchTextLength, - int searchTextIndex, final Object callbackParameter) { + private static boolean matches(final TrieNode startNode, final T searchText, + int searchTextIndex, final int searchTextEndIndex, + final Object callbackParameter) { TrieNode node = startNode; int currentMatchLength = 0; while (true) { TrieCompressedPath leaf = node.leaf; - if (leaf != null && leaf.matches(node, searchText, searchTextLength, searchTextIndex, callbackParameter)) { + if (leaf != null && leaf.matches(startNode, searchText, searchTextEndIndex, searchTextIndex, callbackParameter)) { return true; // Leaf exists and it matched the search text. } List> endOfPatternCallback = node.endOfPatternCallback; @@ -266,7 +250,7 @@ public abstract class TrieSearch { if (children == null) { return false; // Reached a graph end point and there's no further patterns to search. } - if (searchTextIndex == searchTextLength) { + if (searchTextIndex == searchTextEndIndex) { return false; // Reached end of the search text and found no matches. } @@ -323,8 +307,10 @@ public abstract class TrieSearch { */ private final List patterns = new ArrayList<>(); - TrieSearch(@NonNull TrieNode root) { + @SafeVarargs + TrieSearch(@NonNull TrieNode root, @NonNull T... patterns) { this.root = Objects.requireNonNull(root); + addPatterns(patterns); } @SafeVarargs @@ -355,7 +341,7 @@ public abstract class TrieSearch { if (patternLength == 0) return; // Nothing to match patterns.add(pattern); - root.addPattern(pattern, patternLength, 0, callback); + root.addPattern(pattern, 0, patternLength, callback); } public final boolean matches(@NonNull T textToSearch) { @@ -398,7 +384,7 @@ public abstract class TrieSearch { return false; // No patterns were added. } for (int i = startIndex; i < endIndex; i++) { - if (TrieNode.matches(root, textToSearch, endIndex, i, callbackParameter)) return true; + if (TrieNode.matches(root, textToSearch, i, endIndex, callbackParameter)) return true; } return false; } diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/NavigationButtonsPatch.java b/app/src/main/java/app/revanced/integrations/youtube/patches/NavigationButtonsPatch.java index c063d418..bd5f6cfb 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/patches/NavigationButtonsPatch.java +++ b/app/src/main/java/app/revanced/integrations/youtube/patches/NavigationButtonsPatch.java @@ -1,40 +1,41 @@ package app.revanced.integrations.youtube.patches; +import static app.revanced.integrations.youtube.shared.NavigationBar.NavigationButton; import android.view.View; +import java.util.EnumMap; +import java.util.Map; + import app.revanced.integrations.youtube.settings.Settings; @SuppressWarnings("unused") public final class NavigationButtonsPatch { - public static Enum lastNavigationButton; - public static void hideCreateButton(final View view) { - view.setVisibility(Settings.HIDE_CREATE_BUTTON.get() ? View.GONE : View.VISIBLE); - } + private static final Map shouldHideMap = new EnumMap<>(NavigationButton.class) { + { + put(NavigationButton.HOME, Settings.HIDE_HOME_BUTTON.get()); + put(NavigationButton.CREATE, Settings.HIDE_CREATE_BUTTON.get()); + put(NavigationButton.SHORTS, Settings.HIDE_SHORTS_BUTTON.get()); + } + }; + private static final Boolean SWITCH_CREATE_WITH_NOTIFICATIONS_BUTTON + = Settings.SWITCH_CREATE_WITH_NOTIFICATIONS_BUTTON.get(); + + /** + * Injection point. + */ public static boolean switchCreateWithNotificationButton() { - return Settings.SWITCH_CREATE_WITH_NOTIFICATIONS_BUTTON.get(); + return SWITCH_CREATE_WITH_NOTIFICATIONS_BUTTON; } - public static void hideButton(final View buttonView) { - if (lastNavigationButton == null) return; - - for (NavigationButton button : NavigationButton.values()) - if (button.name.equals(lastNavigationButton.name())) - if (button.enabled) buttonView.setVisibility(View.GONE); - } - - private enum NavigationButton { - HOME("PIVOT_HOME", Settings.HIDE_HOME_BUTTON.get()), - SHORTS("TAB_SHORTS", Settings.HIDE_SHORTS_BUTTON.get()), - SUBSCRIPTIONS("PIVOT_SUBSCRIPTIONS", Settings.HIDE_SUBSCRIPTIONS_BUTTON.get()); - private final boolean enabled; - private final String name; - - NavigationButton(final String name, final boolean enabled) { - this.name = name; - this.enabled = enabled; + /** + * Injection point. + */ + public static void navigationTabCreated(NavigationButton button, View tabView) { + if (Boolean.TRUE.equals(shouldHideMap.get(button))) { + tabView.setVisibility(View.GONE); } } } diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/components/CustomFilter.java b/app/src/main/java/app/revanced/integrations/youtube/patches/components/CustomFilter.java index f3c59660..3e62d040 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/patches/components/CustomFilter.java +++ b/app/src/main/java/app/revanced/integrations/youtube/patches/components/CustomFilter.java @@ -17,7 +17,6 @@ import java.util.regex.Pattern; import app.revanced.integrations.shared.Logger; import app.revanced.integrations.shared.Utils; import app.revanced.integrations.youtube.ByteTrieSearch; -import app.revanced.integrations.youtube.StringTrieSearch; import app.revanced.integrations.youtube.settings.Settings; /** @@ -30,10 +29,6 @@ final class CustomFilter extends Filter { Utils.showToastLong(str("revanced_custom_filter_toast_invalid_syntax", expression)); } - private static void showInvalidCharactersToast(@NonNull String expression) { - Utils.showToastLong(str("revanced_custom_filter_toast_invalid_characters", expression)); - } - private static class CustomFilterGroup extends StringFilterGroup { /** * Optional character for the path that indicates the custom filter path must match the start. @@ -73,7 +68,7 @@ final class CustomFilter extends Filter { Matcher matcher = pattern.matcher(expression); if (!matcher.find()) { showInvalidSyntaxToast(expression); - return null; + continue; } final String mapKey = matcher.group(1); @@ -84,13 +79,7 @@ final class CustomFilter extends Filter { if (path.isBlank() || (hasBufferSymbol && bufferString.isBlank())) { showInvalidSyntaxToast(expression); - return null; - } - if (!StringTrieSearch.isValidPattern(path) - || (hasBufferSymbol && !StringTrieSearch.isValidPattern(bufferString))) { - // Currently only ASCII is allowed. - showInvalidCharactersToast(path); - return null; + continue; } // Use one group object for all expressions with the same path. @@ -149,11 +138,6 @@ final class CustomFilter extends Filter { public CustomFilter() { Collection groups = CustomFilterGroup.parseCustomFilterGroups(); - if (groups == null) { - Settings.CUSTOM_FILTER_STRINGS.resetToDefault(); - Utils.showToastLong(str("revanced_custom_filter_toast_reset")); - groups = Objects.requireNonNull(CustomFilterGroup.parseCustomFilterGroups()); - } if (!groups.isEmpty()) { CustomFilterGroup[] groupsArray = groups.toArray(new CustomFilterGroup[0]); diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/components/KeywordContentFilter.java b/app/src/main/java/app/revanced/integrations/youtube/patches/components/KeywordContentFilter.java new file mode 100644 index 00000000..6ea91beb --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/youtube/patches/components/KeywordContentFilter.java @@ -0,0 +1,284 @@ +package app.revanced.integrations.youtube.patches.components; + +import static app.revanced.integrations.shared.StringRef.str; +import static app.revanced.integrations.youtube.ByteTrieSearch.convertStringsToBytes; +import static app.revanced.integrations.youtube.shared.NavigationBar.NavigationButton; + +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; + +import app.revanced.integrations.shared.Logger; +import app.revanced.integrations.shared.Utils; +import app.revanced.integrations.youtube.ByteTrieSearch; +import app.revanced.integrations.youtube.settings.Settings; +import app.revanced.integrations.youtube.shared.NavigationBar; +import app.revanced.integrations.youtube.shared.PlayerType; + +/** + *
+ * Allows hiding home feed and search results based on keywords and/or channel names.
+ *
+ * Limitations:
+ * - Searching for a keyword phrase will give no search results.
+ *   This is because the buffer for each video contains the text the user searched for, and everything
+ *   will be filtered away (even if that video title/channel does not contain any keywords).
+ * - Filtering a channel name can still show Shorts from that channel in the search results.
+ *   The most common Shorts layouts do not include the channel name, so they will not be filtered.
+ * - Some layout component residue will remain, such as the video chapter previews for some search results.
+ *   These components do not include the video title or channel name, and they
+ *   appear outside the filtered components so they are not caught.
+ * - Keywords are case sensitive, but some casing variation is manually added.
+ *   (ie: "mr beast" automatically filters "Mr Beast" and "MR BEAST").
+ * - Keywords present in the layout or video data cannot be used as filters, otherwise all videos
+ *   will always be hidden.  This patch checks for some words of these words.
+ */
+@SuppressWarnings("unused")
+@RequiresApi(api = Build.VERSION_CODES.N)
+final class KeywordContentFilter extends Filter {
+
+    /**
+     * Minimum keyword/phrase length to prevent excessively broad content filtering.
+     */
+    private static final int MINIMUM_KEYWORD_LENGTH = 3;
+
+    /**
+     * Strings found in the buffer for every videos.
+     * Full strings should be specified, as they are compared using {@link String#contains(CharSequence)}.
+     *
+     * This list does not include every common buffer string, and this can be added/changed as needed.
+     * Words must be entered with the exact casing as found in the buffer.
+     */
+    private static final String[] STRINGS_IN_EVERY_BUFFER = {
+            // Video playback data.
+            "https://i.ytimg.com/vi/", // Thumbnail url.
+            "sddefault.jpg", // More video sizes exist, but for most devices only these 2 are used.
+            "hqdefault.webp",
+            "googlevideo.com/initplayback?source=youtube", // Video url.
+            "ANDROID", // Video url parameter.
+            // Video decoders.
+            "OMX.ffmpeg.vp9.decoder",
+            "OMX.Intel.sw_vd.vp9",
+            "OMX.sprd.av1.decoder",
+            "OMX.MTK.VIDEO.DECODER.SW.VP9",
+            "c2.android.av1.decoder",
+            "c2.mtk.sw.vp9.decoder",
+            // User analytics.
+            "https://ad.doubleclick.net/ddm/activity/",
+            "DEVICE_ADVERTISER_ID_FOR_CONVERSION_TRACKING",
+            // Litho components frequently found in the buffer that belong to the path filter items.
+            "metadata.eml",
+            "thumbnail.eml",
+            "avatar.eml",
+            "overflow_button.eml",
+    };
+
+    /**
+     * Substrings that are always first in the identifier.
+     */
+    private final StringFilterGroup startsWithFilter = new StringFilterGroup(
+            null, // Multiple settings are used and must be individually checked if active.
+            "home_video_with_context.eml",
+            "search_video_with_context.eml",
+            "video_with_context.eml", // Subscription tab videos.
+            "related_video_with_context.eml",
+            "compact_video.eml",
+            "inline_shorts",
+            "shorts_video_cell",
+            "shorts_pivot_item.eml"
+    );
+
+    /**
+     * Substrings that are never at the start of the path.
+     */
+    private final StringFilterGroup containsFilter = new StringFilterGroup(
+            null,
+            "modern_type_shelf_header_content.eml",
+             "shorts_lockup_cell.eml" // Part of 'shorts_shelf_carousel.eml'
+    );
+
+    /**
+     * The last value of {@link Settings#HIDE_KEYWORD_CONTENT_PHRASES}
+     * parsed and loaded into {@link #bufferSearch}.
+     * Allows changing the keywords without restarting the app.
+     */
+    private volatile String lastKeywordPhrasesParsed;
+
+    private volatile ByteTrieSearch bufferSearch;
+
+    /**
+     * Change first letter of the first word to use title case.
+     */
+    private static String titleCaseFirstWordOnly(String sentence) {
+        if (sentence.isEmpty()) {
+            return sentence;
+        }
+        final int firstCodePoint = sentence.codePointAt(0);
+        // In some non English languages title case is different than upper case.
+        return new StringBuilder()
+                .appendCodePoint(Character.toTitleCase(firstCodePoint))
+                .append(sentence, Character.charCount(firstCodePoint), sentence.length())
+                .toString();
+    }
+
+    /**
+     * Uppercase the first letter of each word.
+     */
+    private static String capitalizeAllFirstLetters(String sentence) {
+        if (sentence.isEmpty()) {
+            return sentence;
+        }
+        final int delimiter = ' ';
+        // Use code points and not characters to handle unicode surrogates.
+        int[] codePoints = sentence.codePoints().toArray();
+        boolean capitalizeNext = true;
+        for (int i = 0, length = codePoints.length; i < length; i++) {
+            final int codePoint = codePoints[i];
+            if (codePoint == delimiter) {
+                capitalizeNext = true;
+            } else if (capitalizeNext) {
+                codePoints[i] = Character.toUpperCase(codePoint);
+                capitalizeNext = false;
+            }
+        }
+        return new String(codePoints, 0, codePoints.length);
+    }
+
+    /**
+     * @return If the phrase will will hide all videos. Not an exhaustive check.
+     */
+    private static boolean phrasesWillHideAllVideos(@NonNull String[] phrases) {
+        for (String commonString : STRINGS_IN_EVERY_BUFFER) {
+            if (Utils.containsAny(commonString, phrases)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private synchronized void parseKeywords() { // Must be synchronized since Litho is multi-threaded.
+        String rawKeywords = Settings.HIDE_KEYWORD_CONTENT_PHRASES.get();
+        if (rawKeywords == lastKeywordPhrasesParsed) {
+            Logger.printDebug(() -> "Using previously initialized search");
+            return; // Another thread won the race, and search is already initialized.
+        }
+
+        ByteTrieSearch search = new ByteTrieSearch();
+        String[] split = rawKeywords.split("\n");
+        if (split.length != 0) {
+            // Linked Set so log statement are more organized and easier to read.
+            Set keywords = new LinkedHashSet<>(10 * split.length);
+
+            for (String phrase : split) {
+                // Remove any trailing white space the user may have accidentally included.
+                phrase = phrase.stripTrailing();
+                if (phrase.isBlank()) continue;
+
+                if (phrase.length() < MINIMUM_KEYWORD_LENGTH) {
+                    // Do not reset the setting. Keep the invalid keywords so the user can fix the mistake.
+                    Utils.showToastLong(str("revanced_hide_keyword_toast_invalid_length", phrase, MINIMUM_KEYWORD_LENGTH));
+                    continue;
+                }
+
+                // Add common casing that might appear.
+                //
+                // This could be simplified by adding case insensitive search to the prefix search,
+                // which is very simple to add to StringTreSearch for Unicode and ByteTrieSearch for ASCII.
+                //
+                // But to support Unicode with ByteTrieSearch would require major changes because
+                // UTF-8 characters can be different byte lengths, which does
+                // not allow comparing two different byte arrays using simple plain array indexes.
+                //
+                // Instead add all common case variations of the words.
+                String[] phraseVariations = {
+                        phrase,
+                        phrase.toLowerCase(),
+                        titleCaseFirstWordOnly(phrase),
+                        capitalizeAllFirstLetters(phrase),
+                        phrase.toUpperCase()
+                };
+                if (phrasesWillHideAllVideos(phraseVariations)) {
+                    Utils.showToastLong(str("revanced_hide_keyword_toast_invalid_common", phrase));
+                    continue;
+                }
+
+                keywords.addAll(Arrays.asList(phraseVariations));
+            }
+
+            search.addPatterns(convertStringsToBytes(keywords.toArray(new String[0])));
+            Logger.printDebug(() -> "Search using: (" + search.getEstimatedMemorySize() + " KB) keywords: " + keywords);
+        }
+
+        bufferSearch = search;
+        lastKeywordPhrasesParsed = rawKeywords; // Must set last.
+    }
+
+    public KeywordContentFilter() {
+        // Keywords are parsed on first call to isFiltered()
+        addPathCallbacks(startsWithFilter, containsFilter);
+    }
+
+    private static void logNavigationState(String state) {
+        // Enable locally to debug filtering. Default off to reduce log spam.
+        final boolean LOG_NAVIGATION_STATE = false;
+        // noinspection ConstantValue
+        if (LOG_NAVIGATION_STATE) {
+            Logger.printDebug(() -> "Navigation state: " + state);
+        }
+    }
+
+    @Override
+    public boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
+                              StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+        if (contentIndex != 0 && matchedGroup == startsWithFilter) {
+            return false;
+        }
+
+        if (NavigationBar.isSearchBarActive()) {
+            // Search bar can be active with almost any tab active.
+            if (!Settings.HIDE_KEYWORD_CONTENT_SEARCH.get()) {
+                return false;
+            }
+            logNavigationState("Search");
+        } else if (PlayerType.getCurrent().isMaximizedOrFullscreen()) {
+            // For now, consider the under video results the same as the home feed.
+            if (!Settings.HIDE_KEYWORD_CONTENT_HOME.get()) {
+                return false;
+            }
+            logNavigationState("Player active");
+        } else if (NavigationButton.HOME.isSelected()) {
+            // Could use a Switch statement, but there is only 2 tabs of interest.
+            if (!Settings.HIDE_KEYWORD_CONTENT_HOME.get()) {
+                return false;
+            }
+            logNavigationState("Home tab");
+        } else if (NavigationButton.SUBSCRIPTIONS.isSelected()) {
+            if (!Settings.HIDE_SUBSCRIPTIONS_BUTTON.get()) {
+                return false;
+            }
+            logNavigationState("Subscription tab");
+        } else {
+            // User is in the Library or Notifications tab.
+            logNavigationState("Ignored tab");
+            return false;
+        }
+
+        // Field is intentionally compared using reference equality.
+        if (Settings.HIDE_KEYWORD_CONTENT_PHRASES.get() != lastKeywordPhrasesParsed) {
+            // User changed the keywords.
+            parseKeywords();
+        }
+
+        if (!bufferSearch.matches(protobufBufferArray)) {
+            return false;
+        }
+
+        return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/components/LithoFilterPatch.java b/app/src/main/java/app/revanced/integrations/youtube/patches/components/LithoFilterPatch.java
index 51c36493..21b0129a 100644
--- a/app/src/main/java/app/revanced/integrations/youtube/patches/components/LithoFilterPatch.java
+++ b/app/src/main/java/app/revanced/integrations/youtube/patches/components/LithoFilterPatch.java
@@ -188,9 +188,8 @@ class ByteArrayFilterGroup extends FilterGroup {
     /**
      * Converts the Strings into byte arrays. Used to search for text in binary data.
      */
-    @RequiresApi(api = Build.VERSION_CODES.N)
     public ByteArrayFilterGroup(BooleanSetting setting, String... filters) {
-        super(setting, Arrays.stream(filters).map(String::getBytes).toArray(byte[][]::new));
+        super(setting, ByteTrieSearch.convertStringsToBytes(filters));
     }
 
     private synchronized void buildFailurePatterns() {
diff --git a/app/src/main/java/app/revanced/integrations/youtube/settings/LicenseActivityHook.java b/app/src/main/java/app/revanced/integrations/youtube/settings/LicenseActivityHook.java
index 30d97a67..3f51c803 100644
--- a/app/src/main/java/app/revanced/integrations/youtube/settings/LicenseActivityHook.java
+++ b/app/src/main/java/app/revanced/integrations/youtube/settings/LicenseActivityHook.java
@@ -70,14 +70,16 @@ public class LicenseActivityHook {
 
     private static void setToolbarTitle(Activity activity, String toolbarTitleResourceName) {
         ViewGroup toolbar = activity.findViewById(getToolbarResourceId());
-        TextView toolbarTextView = Objects.requireNonNull(getChildView(toolbar, view -> view instanceof TextView));
+        TextView toolbarTextView = Objects.requireNonNull(getChildView(toolbar, false,
+                view -> view instanceof TextView));
         toolbarTextView.setText(getResourceIdentifier(toolbarTitleResourceName, "string"));
     }
 
     @SuppressLint("UseCompatLoadingForDrawables")
     private static void setBackButton(Activity activity) {
         ViewGroup toolbar = activity.findViewById(getToolbarResourceId());
-        ImageButton imageButton = Objects.requireNonNull(getChildView(toolbar, view -> view instanceof ImageButton));
+        ImageButton imageButton = Objects.requireNonNull(getChildView(toolbar, false,
+                view -> view instanceof ImageButton));
         final int backButtonResource = getResourceIdentifier(ThemeHelper.isDarkTheme()
                         ? "yt_outline_arrow_left_white_24"
                         : "yt_outline_arrow_left_black_24",
diff --git a/app/src/main/java/app/revanced/integrations/youtube/settings/Settings.java b/app/src/main/java/app/revanced/integrations/youtube/settings/Settings.java
index 19da334d..8f2c43e4 100644
--- a/app/src/main/java/app/revanced/integrations/youtube/settings/Settings.java
+++ b/app/src/main/java/app/revanced/integrations/youtube/settings/Settings.java
@@ -98,6 +98,11 @@ public class Settings extends BaseSettings {
     public static final BooleanSetting HIDE_IMAGE_SHELF = new BooleanSetting("revanced_hide_image_shelf", TRUE);
     public static final BooleanSetting HIDE_INFO_CARDS = new BooleanSetting("revanced_hide_info_cards", TRUE);
     public static final BooleanSetting HIDE_JOIN_MEMBERSHIP_BUTTON = new BooleanSetting("revanced_hide_join_membership_button", TRUE);
+    public static final BooleanSetting HIDE_KEYWORD_CONTENT_SEARCH = new BooleanSetting("revanced_hide_keyword_content_search", FALSE);
+    public static final BooleanSetting HIDE_KEYWORD_CONTENT_HOME = new BooleanSetting("revanced_hide_keyword_content_home", FALSE);
+    public static final BooleanSetting HIDE_KEYWORD_CONTENT_SUBSCRIPTIONS = new BooleanSetting("revanced_hide_keyword_content_subscriptions", FALSE);
+    public static final StringSetting HIDE_KEYWORD_CONTENT_PHRASES = new StringSetting("revanced_hide_keyword_content_phrases", "",
+            parentsAny(HIDE_KEYWORD_CONTENT_SEARCH, HIDE_KEYWORD_CONTENT_HOME, HIDE_KEYWORD_CONTENT_SUBSCRIPTIONS));
     public static final BooleanSetting HIDE_LOAD_MORE_BUTTON = new BooleanSetting("revanced_hide_load_more_button", TRUE, true);
     public static final BooleanSetting HIDE_MEDICAL_PANELS = new BooleanSetting("revanced_hide_medical_panels", TRUE);
     public static final BooleanSetting HIDE_MIX_PLAYLISTS = new BooleanSetting("revanced_hide_mix_playlists", TRUE);
@@ -227,6 +232,10 @@ public class Settings extends BaseSettings {
             parentsAny(SWIPE_BRIGHTNESS, SWIPE_VOLUME));
 
     // Debugging
+    /**
+     * When enabled, share the debug logs with care.
+     * The buffer contains select user data, including the client ip address and information that could identify the YT account.
+     */
     public static final BooleanSetting DEBUG_PROTOBUFFER = new BooleanSetting("revanced_debug_protobuffer", FALSE, parent(BaseSettings.DEBUG));
 
     // ReturnYoutubeDislike
diff --git a/app/src/main/java/app/revanced/integrations/youtube/shared/NavigationBar.java b/app/src/main/java/app/revanced/integrations/youtube/shared/NavigationBar.java
new file mode 100644
index 00000000..53155a18
--- /dev/null
+++ b/app/src/main/java/app/revanced/integrations/youtube/shared/NavigationBar.java
@@ -0,0 +1,184 @@
+package app.revanced.integrations.youtube.shared;
+
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import androidx.annotation.Nullable;
+import app.revanced.integrations.shared.Logger;
+import app.revanced.integrations.shared.Utils;
+import app.revanced.integrations.youtube.settings.Settings;
+
+import java.lang.ref.WeakReference;
+
+import static app.revanced.integrations.youtube.shared.NavigationBar.NavigationButton.CREATE;
+
+@SuppressWarnings("unused")
+public final class NavigationBar {
+    private static volatile boolean searchbarIsActive;
+
+    /**
+     * Injection point.
+     */
+    public static void searchBarResultsViewLoaded(View searchbarResults) {
+        searchbarResults.getViewTreeObserver().addOnGlobalLayoutListener(() -> {
+            final boolean isActive = searchbarResults.getParent() != null;
+
+            if (searchbarIsActive != isActive) {
+                searchbarIsActive = isActive;
+                Logger.printDebug(() -> "searchbarIsActive: " + isActive);
+            }
+        });
+    }
+
+    public static boolean isSearchBarActive() {
+        return searchbarIsActive;
+    }
+
+
+    /**
+     * Last YT navigation enum loaded.  Not necessarily the active navigation tab.
+     */
+    @Nullable
+    private static volatile String lastYTNavigationEnumName;
+
+    /**
+     * Injection point.
+     */
+    public static void setLastAppNavigationEnum(@Nullable Enum ytNavigationEnumName) {
+        if (ytNavigationEnumName != null) {
+            lastYTNavigationEnumName = ytNavigationEnumName.name();
+        }
+    }
+
+    /**
+     * Injection point.
+     */
+    public static void navigationTabLoaded(final View navigationButtonGroup) {
+        try {
+            String lastEnumName = lastYTNavigationEnumName;
+            for (NavigationButton button : NavigationButton.values()) {
+                if (button.ytEnumName.equals(lastEnumName)) {
+                    ImageView imageView = Utils.getChildView((ViewGroup) navigationButtonGroup,
+                            true, view -> view instanceof ImageView);
+
+                    if (imageView != null) {
+                        Logger.printDebug(() -> "navigationTabLoaded: " + lastEnumName);
+
+                        button.imageViewRef = new WeakReference<>(imageView);
+                        navigationTabCreatedCallback(button, navigationButtonGroup);
+
+                        return;
+                    }
+                }
+            }
+            // Log the unknown tab as exception level, only if debug is enabled.
+            // This is because unknown tabs do no harm, and it's only relevant to developers.
+            if (Settings.DEBUG.get()) {
+                Logger.printException(() -> "Unknown tab: " + lastEnumName
+                        + " view: " + navigationButtonGroup.getClass());
+            }
+        } catch (Exception ex) {
+            Logger.printException(() -> "navigationTabLoaded failure", ex);
+        }
+    }
+
+    /**
+     * Injection point.
+     *
+     * Unique hook just for the 'Create' and 'You' tab.
+     */
+    public static void navigationImageResourceTabLoaded(View view) {
+        // 'You' tab has no YT enum name and the enum hook is not called for it.
+        // Compare the last enum to figure out which tab this actually is.
+        if (CREATE.ytEnumName.equals(lastYTNavigationEnumName)) {
+            navigationTabLoaded(view);
+        } else {
+            lastYTNavigationEnumName = NavigationButton.LIBRARY_YOU.ytEnumName;
+            navigationTabLoaded(view);
+        }
+    }
+
+    /** @noinspection EmptyMethod*/
+    private static void navigationTabCreatedCallback(NavigationBar.NavigationButton button, View tabView) {
+        // Code is added during patching.
+    }
+
+    public enum NavigationButton {
+        HOME("PIVOT_HOME"),
+        SHORTS("TAB_SHORTS"),
+        /**
+         * Create new video tab.
+         *
+         * {@link #isSelected()} always returns false, even if the create video UI is on screen.
+         */
+        CREATE("CREATION_TAB_LARGE"),
+        SUBSCRIPTIONS("PIVOT_SUBSCRIPTIONS"),
+        /**
+         * Notifications tab.  Only present when
+         * {@link Settings#SWITCH_CREATE_WITH_NOTIFICATIONS_BUTTON} is active.
+         */
+        ACTIVITY("TAB_ACTIVITY"),
+        /**
+         * Library tab when the user is not logged in.
+         */
+        LIBRARY_LOGGED_OUT("ACCOUNT_CIRCLE"),
+        /**
+         * User is logged in with incognito mode enabled.
+         */
+        LIBRARY_INCOGNITO("INCOGNITO_CIRCLE"),
+        /**
+         * Old library tab (pre 'You' layout), only present when version spoofing.
+         */
+        LIBRARY_OLD_UI("VIDEO_LIBRARY_WHITE"),
+        /**
+         * 'You' library tab that is sometimes momentarily loaded.
+         * When this is loaded, {@link #LIBRARY_YOU} is also present.
+         *
+         * This might be a temporary tab while the user profile photo is loading,
+         * but its exact purpose is not entirely clear.
+         */
+        LIBRARY_PIVOT_UNKNOWN("PIVOT_LIBRARY"),
+        /**
+         * Modern library tab with 'You' layout.
+         */
+        // The hooked YT code does not use an enum, and a dummy name is used here.
+        LIBRARY_YOU("YOU_LIBRARY_DUMMY_PLACEHOLDER_NAME");
+
+        /**
+         * @return The active navigation tab.
+         *         If the user is in the create new video UI, this returns NULL.
+         */
+        @Nullable
+        public static NavigationButton getSelectedNavigationButton() {
+            for (NavigationButton button : values()) {
+                if (button.isSelected()) return button;
+            }
+            return null;
+        }
+
+        /**
+         * @return If the currently selected tab is a 'You' or library type.
+         *         Covers all known app states including incognito mode and version spoofing.
+         */
+        public static boolean libraryOrYouTabIsSelected() {
+            return LIBRARY_YOU.isSelected() || LIBRARY_PIVOT_UNKNOWN.isSelected()
+                    || LIBRARY_OLD_UI.isSelected() || LIBRARY_INCOGNITO.isSelected()
+                    || LIBRARY_LOGGED_OUT.isSelected();
+        }
+
+        /**
+         * YouTube enum name for this tab.
+         */
+        private final String ytEnumName;
+        private volatile WeakReference imageViewRef = new WeakReference<>(null);
+
+        NavigationButton(String ytEnumName) {
+            this.ytEnumName = ytEnumName;
+        }
+
+        public boolean isSelected() {
+            ImageView view = imageViewRef.get();
+            return view != null && view.isSelected();
+        }
+    }
+}
diff --git a/app/src/main/java/app/revanced/integrations/youtube/shared/PlayerType.kt b/app/src/main/java/app/revanced/integrations/youtube/shared/PlayerType.kt
index 15d1496d..7db4a3fd 100644
--- a/app/src/main/java/app/revanced/integrations/youtube/shared/PlayerType.kt
+++ b/app/src/main/java/app/revanced/integrations/youtube/shared/PlayerType.kt
@@ -132,4 +132,8 @@ enum class PlayerType {
     fun isNoneHiddenOrMinimized(): Boolean {
         return isNoneHiddenOrSlidingMinimized() || this == WATCH_WHILE_MINIMIZED
     }
+
+    fun isMaximizedOrFullscreen(): Boolean {
+        return this == WATCH_WHILE_MAXIMIZED || this == WATCH_WHILE_FULLSCREEN
+    }
 }