feat(YouTube - Hide layout components): Filter home/search results by keywords (#584)

Co-authored-by: oSumAtrIX <johan.melkonyan1@web.de>
This commit is contained in:
LisoUseInAIKyrios 2024-03-27 11:26:26 +04:00 committed by GitHub
parent 2a56cc09c9
commit 0222a4aafc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 563 additions and 109 deletions

View File

@ -196,18 +196,29 @@ public class Utils {
return getContext().getResources().getDimension(getResourceIdentifier(resourceIdentifierName, "dimen"));
}
public interface MatchFilter<T> {
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 extends View> T getChildView(@NonNull ViewGroup viewGroup, @NonNull MatchFilter filter) {
public static <T extends View> T getChildView(@NonNull ViewGroup viewGroup, boolean searchRecursively,
@NonNull MatchFilter<View> 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<T> {
boolean matches(T object);
}
public static Context getContext() {
if (context == null) {
Logger.initializationException(Utils.class, "Context is null, returning null!", null);

View File

@ -1,5 +1,9 @@
package app.revanced.integrations.youtube;
import androidx.annotation.NonNull;
import java.nio.charset.StandardCharsets;
public final class ByteTrieSearch extends TrieSearch<byte[]> {
private static final class ByteTrieNode extends TrieNode<byte[]> {
@ -24,18 +28,18 @@ public final class ByteTrieSearch extends TrieSearch<byte[]> {
}
/**
* @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);
}
}

View File

@ -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<String> {
}
}
/**
* @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);
}
}

View File

@ -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<T> {
@ -45,14 +42,14 @@ public abstract class TrieSearch<T> {
*/
private static final class TrieCompressedPath<T> {
final T pattern;
final int patternLength;
final int patternStartIndex;
final int patternLength;
final TriePatternMatchedCallback<T> callback;
TrieCompressedPath(T pattern, int patternLength, int patternStartIndex, TriePatternMatchedCallback<T> callback) {
TrieCompressedPath(T pattern, int patternStartIndex, int patternLength, TriePatternMatchedCallback<T> callback) {
this.pattern = pattern;
this.patternLength = patternLength;
this.patternStartIndex = patternStartIndex;
this.patternLength = patternLength;
this.callback = callback;
}
boolean matches(TrieNode<T> enclosingNode, // Used only for the get character method.
@ -76,19 +73,10 @@ public abstract class TrieSearch<T> {
*/
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<T> {
/**
* @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<T> callback) {
if (patternIndex == patternLength) { // Reached the end of the pattern.
if (endOfPatternCallback == null) {
@ -165,16 +153,13 @@ public abstract class TrieSearch<T> {
children = new TrieNode[1];
TrieCompressedPath<T> 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<T> child = children[arrayIndex];
if (child == null) {
@ -185,12 +170,11 @@ public abstract class TrieSearch<T> {
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<T> child) {
int replacementArraySize = Objects.requireNonNull(children).length;
@ -209,7 +193,6 @@ public abstract class TrieSearch<T> {
}
}
if (collision) {
if (replacementArraySize > CHILDREN_ARRAY_MAX_SIZE) throw new IllegalStateException();
continue;
}
children = replacement;
@ -232,22 +215,23 @@ public abstract class TrieSearch<T> {
/**
* 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 <T> boolean matches(final TrieNode<T> startNode, final T searchText, final int searchTextLength,
int searchTextIndex, final Object callbackParameter) {
private static <T> boolean matches(final TrieNode<T> startNode, final T searchText,
int searchTextIndex, final int searchTextEndIndex,
final Object callbackParameter) {
TrieNode<T> node = startNode;
int currentMatchLength = 0;
while (true) {
TrieCompressedPath<T> 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<TriePatternMatchedCallback<T>> endOfPatternCallback = node.endOfPatternCallback;
@ -266,7 +250,7 @@ public abstract class TrieSearch<T> {
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<T> {
*/
private final List<T> patterns = new ArrayList<>();
TrieSearch(@NonNull TrieNode<T> root) {
@SafeVarargs
TrieSearch(@NonNull TrieNode<T> root, @NonNull T... patterns) {
this.root = Objects.requireNonNull(root);
addPatterns(patterns);
}
@SafeVarargs
@ -355,7 +341,7 @@ public abstract class TrieSearch<T> {
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<T> {
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;
}

View File

@ -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<NavigationButton, Boolean> 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);
}
}
}

View File

@ -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<CustomFilterGroup> 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]);

View File

@ -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;
/**
* <pre>
* 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<String> 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);
}
}

View File

@ -188,9 +188,8 @@ class ByteArrayFilterGroup extends FilterGroup<byte[]> {
/**
* 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() {

View File

@ -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",

View File

@ -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

View File

@ -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<ImageView> imageViewRef = new WeakReference<>(null);
NavigationButton(String ytEnumName) {
this.ytEnumName = ytEnumName;
}
public boolean isSelected() {
ImageView view = imageViewRef.get();
return view != null && view.isSelected();
}
}
}

View File

@ -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
}
}