mirror of
https://github.com/revanced/revanced-integrations
synced 2024-11-24 20:07:14 +01:00
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:
parent
6e947e24c2
commit
0cbad98205
@ -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);
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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]);
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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() {
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user