mirror of
https://github.com/revanced/revanced-integrations.git
synced 2024-08-31 11:35:24 +02: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"));
|
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.
|
* @return The first child view that matches the filter.
|
||||||
*/
|
*/
|
||||||
@Nullable
|
@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++) {
|
for (int i = 0, childCount = viewGroup.getChildCount(); i < childCount; i++) {
|
||||||
View childAt = viewGroup.getChildAt(i);
|
View childAt = viewGroup.getChildAt(i);
|
||||||
//noinspection unchecked
|
|
||||||
if (filter.matches(childAt)) {
|
if (filter.matches(childAt)) {
|
||||||
//noinspection unchecked
|
//noinspection unchecked
|
||||||
return (T) childAt;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
@ -223,10 +234,6 @@ public class Utils {
|
|||||||
System.exit(0);
|
System.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface MatchFilter<T> {
|
|
||||||
boolean matches(T object);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Context getContext() {
|
public static Context getContext() {
|
||||||
if (context == null) {
|
if (context == null) {
|
||||||
Logger.initializationException(Utils.class, "Context is null, returning null!", null);
|
Logger.initializationException(Utils.class, "Context is null, returning null!", null);
|
||||||
|
@ -1,5 +1,9 @@
|
|||||||
package app.revanced.integrations.youtube;
|
package app.revanced.integrations.youtube;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
public final class ByteTrieSearch extends TrieSearch<byte[]> {
|
public final class ByteTrieSearch extends TrieSearch<byte[]> {
|
||||||
|
|
||||||
private static final class ByteTrieNode extends TrieNode<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) {
|
public static byte[][] convertStringsToBytes(String... strings) {
|
||||||
for (byte b : pattern) {
|
final int length = strings.length;
|
||||||
if (TrieNode.isInvalidRange((char) b)) {
|
byte[][] replacement = new byte[length][];
|
||||||
return false;
|
for (int i = 0; i < length; i++) {
|
||||||
}
|
replacement[i] = strings[i].getBytes(StandardCharsets.UTF_8);
|
||||||
}
|
}
|
||||||
return true;
|
return replacement;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ByteTrieSearch() {
|
public ByteTrieSearch(@NonNull byte[]... patterns) {
|
||||||
super(new ByteTrieNode());
|
super(new ByteTrieNode(), patterns);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
package app.revanced.integrations.youtube;
|
package app.revanced.integrations.youtube;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Text pattern searching using a prefix tree (trie).
|
* Text pattern searching using a prefix tree (trie).
|
||||||
*/
|
*/
|
||||||
@ -26,19 +28,7 @@ public final class StringTrieSearch extends TrieSearch<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public StringTrieSearch(@NonNull String... patterns) {
|
||||||
* @return If the pattern is valid to add to this instance.
|
super(new StringTrieNode(), patterns);
|
||||||
*/
|
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,9 +11,6 @@ import java.util.Objects;
|
|||||||
/**
|
/**
|
||||||
* Searches for a group of different patterns using a trie (prefix tree).
|
* Searches for a group of different patterns using a trie (prefix tree).
|
||||||
* Can significantly speed up searching for multiple patterns.
|
* 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> {
|
public abstract class TrieSearch<T> {
|
||||||
|
|
||||||
@ -45,14 +42,14 @@ public abstract class TrieSearch<T> {
|
|||||||
*/
|
*/
|
||||||
private static final class TrieCompressedPath<T> {
|
private static final class TrieCompressedPath<T> {
|
||||||
final T pattern;
|
final T pattern;
|
||||||
final int patternLength;
|
|
||||||
final int patternStartIndex;
|
final int patternStartIndex;
|
||||||
|
final int patternLength;
|
||||||
final TriePatternMatchedCallback<T> callback;
|
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.pattern = pattern;
|
||||||
this.patternLength = patternLength;
|
|
||||||
this.patternStartIndex = patternStartIndex;
|
this.patternStartIndex = patternStartIndex;
|
||||||
|
this.patternLength = patternLength;
|
||||||
this.callback = callback;
|
this.callback = callback;
|
||||||
}
|
}
|
||||||
boolean matches(TrieNode<T> enclosingNode, // Used only for the get character method.
|
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.
|
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.
|
* 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_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.
|
* Character this node represents.
|
||||||
@ -144,11 +132,11 @@ public abstract class TrieSearch<T> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param pattern Pattern to add.
|
* @param pattern Pattern to add.
|
||||||
* @param patternLength Length of the pattern.
|
|
||||||
* @param patternIndex Current recursive index 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.
|
* @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) {
|
@Nullable TriePatternMatchedCallback<T> callback) {
|
||||||
if (patternIndex == patternLength) { // Reached the end of the pattern.
|
if (patternIndex == patternLength) { // Reached the end of the pattern.
|
||||||
if (endOfPatternCallback == null) {
|
if (endOfPatternCallback == null) {
|
||||||
@ -165,16 +153,13 @@ public abstract class TrieSearch<T> {
|
|||||||
children = new TrieNode[1];
|
children = new TrieNode[1];
|
||||||
TrieCompressedPath<T> temp = leaf;
|
TrieCompressedPath<T> temp = leaf;
|
||||||
leaf = null;
|
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.
|
// Continue onward and add the parameter pattern.
|
||||||
} else if (children == null) {
|
} else if (children == null) {
|
||||||
leaf = new TrieCompressedPath<>(pattern, patternLength, patternIndex, callback);
|
leaf = new TrieCompressedPath<>(pattern, patternIndex, patternLength, callback);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final char character = getCharValue(pattern, patternIndex);
|
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);
|
final int arrayIndex = hashIndexForTableSize(children.length, character);
|
||||||
TrieNode<T> child = children[arrayIndex];
|
TrieNode<T> child = children[arrayIndex];
|
||||||
if (child == null) {
|
if (child == null) {
|
||||||
@ -185,12 +170,11 @@ public abstract class TrieSearch<T> {
|
|||||||
child = createNode(character);
|
child = createNode(character);
|
||||||
expandChildArray(child);
|
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.
|
* 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) {
|
private void expandChildArray(TrieNode<T> child) {
|
||||||
int replacementArraySize = Objects.requireNonNull(children).length;
|
int replacementArraySize = Objects.requireNonNull(children).length;
|
||||||
@ -209,7 +193,6 @@ public abstract class TrieSearch<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (collision) {
|
if (collision) {
|
||||||
if (replacementArraySize > CHILDREN_ARRAY_MAX_SIZE) throw new IllegalStateException();
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
children = replacement;
|
children = replacement;
|
||||||
@ -232,22 +215,23 @@ public abstract class TrieSearch<T> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* This method is static and uses a loop to avoid all recursion.
|
* 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 startNode Node to start the search from.
|
||||||
* @param searchText Text to search for patterns in.
|
* @param searchText Text to search for patterns in.
|
||||||
* @param searchTextLength Length of the search text.
|
* @param searchTextIndex Start index, inclusive.
|
||||||
* @param searchTextIndex Current recursive search text index. Also, the end index of the current pattern match.
|
* @param searchTextEndIndex End index, exclusive.
|
||||||
* @return If any pattern matches, and it's associated callback halted the search.
|
* @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,
|
private static <T> boolean matches(final TrieNode<T> startNode, final T searchText,
|
||||||
int searchTextIndex, final Object callbackParameter) {
|
int searchTextIndex, final int searchTextEndIndex,
|
||||||
|
final Object callbackParameter) {
|
||||||
TrieNode<T> node = startNode;
|
TrieNode<T> node = startNode;
|
||||||
int currentMatchLength = 0;
|
int currentMatchLength = 0;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
TrieCompressedPath<T> leaf = node.leaf;
|
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.
|
return true; // Leaf exists and it matched the search text.
|
||||||
}
|
}
|
||||||
List<TriePatternMatchedCallback<T>> endOfPatternCallback = node.endOfPatternCallback;
|
List<TriePatternMatchedCallback<T>> endOfPatternCallback = node.endOfPatternCallback;
|
||||||
@ -266,7 +250,7 @@ public abstract class TrieSearch<T> {
|
|||||||
if (children == null) {
|
if (children == null) {
|
||||||
return false; // Reached a graph end point and there's no further patterns to search.
|
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.
|
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<>();
|
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);
|
this.root = Objects.requireNonNull(root);
|
||||||
|
addPatterns(patterns);
|
||||||
}
|
}
|
||||||
|
|
||||||
@SafeVarargs
|
@SafeVarargs
|
||||||
@ -355,7 +341,7 @@ public abstract class TrieSearch<T> {
|
|||||||
if (patternLength == 0) return; // Nothing to match
|
if (patternLength == 0) return; // Nothing to match
|
||||||
|
|
||||||
patterns.add(pattern);
|
patterns.add(pattern);
|
||||||
root.addPattern(pattern, patternLength, 0, callback);
|
root.addPattern(pattern, 0, patternLength, callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
public final boolean matches(@NonNull T textToSearch) {
|
public final boolean matches(@NonNull T textToSearch) {
|
||||||
@ -398,7 +384,7 @@ public abstract class TrieSearch<T> {
|
|||||||
return false; // No patterns were added.
|
return false; // No patterns were added.
|
||||||
}
|
}
|
||||||
for (int i = startIndex; i < endIndex; i++) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -1,40 +1,41 @@
|
|||||||
package app.revanced.integrations.youtube.patches;
|
package app.revanced.integrations.youtube.patches;
|
||||||
|
|
||||||
|
import static app.revanced.integrations.youtube.shared.NavigationBar.NavigationButton;
|
||||||
|
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
|
|
||||||
|
import java.util.EnumMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import app.revanced.integrations.youtube.settings.Settings;
|
import app.revanced.integrations.youtube.settings.Settings;
|
||||||
|
|
||||||
@SuppressWarnings("unused")
|
@SuppressWarnings("unused")
|
||||||
public final class NavigationButtonsPatch {
|
public final class NavigationButtonsPatch {
|
||||||
public static Enum lastNavigationButton;
|
|
||||||
|
|
||||||
public static void hideCreateButton(final View view) {
|
private static final Map<NavigationButton, Boolean> shouldHideMap = new EnumMap<>(NavigationButton.class) {
|
||||||
view.setVisibility(Settings.HIDE_CREATE_BUTTON.get() ? View.GONE : View.VISIBLE);
|
{
|
||||||
}
|
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() {
|
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;
|
* Injection point.
|
||||||
|
*/
|
||||||
for (NavigationButton button : NavigationButton.values())
|
public static void navigationTabCreated(NavigationButton button, View tabView) {
|
||||||
if (button.name.equals(lastNavigationButton.name()))
|
if (Boolean.TRUE.equals(shouldHideMap.get(button))) {
|
||||||
if (button.enabled) buttonView.setVisibility(View.GONE);
|
tabView.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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -17,7 +17,6 @@ import java.util.regex.Pattern;
|
|||||||
import app.revanced.integrations.shared.Logger;
|
import app.revanced.integrations.shared.Logger;
|
||||||
import app.revanced.integrations.shared.Utils;
|
import app.revanced.integrations.shared.Utils;
|
||||||
import app.revanced.integrations.youtube.ByteTrieSearch;
|
import app.revanced.integrations.youtube.ByteTrieSearch;
|
||||||
import app.revanced.integrations.youtube.StringTrieSearch;
|
|
||||||
import app.revanced.integrations.youtube.settings.Settings;
|
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));
|
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 {
|
private static class CustomFilterGroup extends StringFilterGroup {
|
||||||
/**
|
/**
|
||||||
* Optional character for the path that indicates the custom filter path must match the start.
|
* 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);
|
Matcher matcher = pattern.matcher(expression);
|
||||||
if (!matcher.find()) {
|
if (!matcher.find()) {
|
||||||
showInvalidSyntaxToast(expression);
|
showInvalidSyntaxToast(expression);
|
||||||
return null;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
final String mapKey = matcher.group(1);
|
final String mapKey = matcher.group(1);
|
||||||
@ -84,13 +79,7 @@ final class CustomFilter extends Filter {
|
|||||||
|
|
||||||
if (path.isBlank() || (hasBufferSymbol && bufferString.isBlank())) {
|
if (path.isBlank() || (hasBufferSymbol && bufferString.isBlank())) {
|
||||||
showInvalidSyntaxToast(expression);
|
showInvalidSyntaxToast(expression);
|
||||||
return null;
|
continue;
|
||||||
}
|
|
||||||
if (!StringTrieSearch.isValidPattern(path)
|
|
||||||
|| (hasBufferSymbol && !StringTrieSearch.isValidPattern(bufferString))) {
|
|
||||||
// Currently only ASCII is allowed.
|
|
||||||
showInvalidCharactersToast(path);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use one group object for all expressions with the same path.
|
// Use one group object for all expressions with the same path.
|
||||||
@ -149,11 +138,6 @@ final class CustomFilter extends Filter {
|
|||||||
|
|
||||||
public CustomFilter() {
|
public CustomFilter() {
|
||||||
Collection<CustomFilterGroup> groups = CustomFilterGroup.parseCustomFilterGroups();
|
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()) {
|
if (!groups.isEmpty()) {
|
||||||
CustomFilterGroup[] groupsArray = groups.toArray(new CustomFilterGroup[0]);
|
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.
|
* 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) {
|
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() {
|
private synchronized void buildFailurePatterns() {
|
||||||
|
@ -70,14 +70,16 @@ public class LicenseActivityHook {
|
|||||||
|
|
||||||
private static void setToolbarTitle(Activity activity, String toolbarTitleResourceName) {
|
private static void setToolbarTitle(Activity activity, String toolbarTitleResourceName) {
|
||||||
ViewGroup toolbar = activity.findViewById(getToolbarResourceId());
|
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"));
|
toolbarTextView.setText(getResourceIdentifier(toolbarTitleResourceName, "string"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("UseCompatLoadingForDrawables")
|
@SuppressLint("UseCompatLoadingForDrawables")
|
||||||
private static void setBackButton(Activity activity) {
|
private static void setBackButton(Activity activity) {
|
||||||
ViewGroup toolbar = activity.findViewById(getToolbarResourceId());
|
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()
|
final int backButtonResource = getResourceIdentifier(ThemeHelper.isDarkTheme()
|
||||||
? "yt_outline_arrow_left_white_24"
|
? "yt_outline_arrow_left_white_24"
|
||||||
: "yt_outline_arrow_left_black_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_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_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_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_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_MEDICAL_PANELS = new BooleanSetting("revanced_hide_medical_panels", TRUE);
|
||||||
public static final BooleanSetting HIDE_MIX_PLAYLISTS = new BooleanSetting("revanced_hide_mix_playlists", 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));
|
parentsAny(SWIPE_BRIGHTNESS, SWIPE_VOLUME));
|
||||||
|
|
||||||
// Debugging
|
// 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));
|
public static final BooleanSetting DEBUG_PROTOBUFFER = new BooleanSetting("revanced_debug_protobuffer", FALSE, parent(BaseSettings.DEBUG));
|
||||||
|
|
||||||
// ReturnYoutubeDislike
|
// 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 {
|
fun isNoneHiddenOrMinimized(): Boolean {
|
||||||
return isNoneHiddenOrSlidingMinimized() || this == WATCH_WHILE_MINIMIZED
|
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