feat(YouTube Music): Add Hide layout components patch (#6365)

Co-authored-by: oSumAtrIX <johan.melkonyan1@web.de>
This commit is contained in:
rospino74 2026-01-22 18:36:37 +01:00 committed by GitHub
parent 156441d3cf
commit 71ce8230a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 1114 additions and 952 deletions

View file

@ -0,0 +1,164 @@
package app.revanced.extension.shared.patches.components;
import static app.revanced.extension.shared.StringRef.str;
import androidx.annotation.NonNull;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.ByteTrieSearch;
import app.revanced.extension.shared.patches.litho.FilterGroup.StringFilterGroup;
import app.revanced.extension.shared.settings.YouTubeAndMusicSettings;
import app.revanced.extension.shared.patches.litho.Filter;
/**
* Allows custom filtering using a path and optionally a proto buffer string.
*/
@SuppressWarnings("unused")
public final class CustomFilter extends Filter {
private static void showInvalidSyntaxToast(@NonNull String expression) {
Utils.showToastLong(str("revanced_custom_filter_toast_invalid_syntax", expression));
}
private static class CustomFilterGroup extends StringFilterGroup {
/**
* Optional character for the path that indicates the custom filter path must match the start.
* Must be the first character of the expression.
*/
public static final String SYNTAX_STARTS_WITH = "^";
/**
* Optional character that separates the path from a proto buffer string pattern.
*/
public static final String SYNTAX_BUFFER_SYMBOL = "$";
/**
* @return the parsed objects
*/
@NonNull
@SuppressWarnings("ConstantConditions")
static Collection<CustomFilterGroup> parseCustomFilterGroups() {
String rawCustomFilterText = YouTubeAndMusicSettings.CUSTOM_FILTER_STRINGS.get();
if (rawCustomFilterText.isBlank()) {
return Collections.emptyList();
}
// Map key is the path including optional special characters (^ and/or $)
Map<String, CustomFilterGroup> result = new HashMap<>();
Pattern pattern = Pattern.compile(
"(" // map key group
+ "(\\Q" + SYNTAX_STARTS_WITH + "\\E?)" // optional starts with
+ "([^\\Q" + SYNTAX_BUFFER_SYMBOL + "\\E]*)" // path
+ "(\\Q" + SYNTAX_BUFFER_SYMBOL + "\\E?)" // optional buffer symbol
+ ")" // end map key group
+ "(.*)"); // optional buffer string
for (String expression : rawCustomFilterText.split("\n")) {
if (expression.isBlank()) continue;
Matcher matcher = pattern.matcher(expression);
if (!matcher.find()) {
showInvalidSyntaxToast(expression);
continue;
}
final String mapKey = matcher.group(1);
final boolean pathStartsWith = !matcher.group(2).isEmpty();
final String path = matcher.group(3);
final boolean hasBufferSymbol = !matcher.group(4).isEmpty();
final String bufferString = matcher.group(5);
if (path.isBlank() || (hasBufferSymbol && bufferString.isBlank())) {
showInvalidSyntaxToast(expression);
continue;
}
// Use one group object for all expressions with the same path.
// This ensures the buffer is searched exactly once
// when multiple paths are used with different buffer strings.
CustomFilterGroup group = result.get(mapKey);
if (group == null) {
group = new CustomFilterGroup(pathStartsWith, path);
result.put(mapKey, group);
}
if (hasBufferSymbol) {
group.addBufferString(bufferString);
}
}
return result.values();
}
final boolean startsWith;
ByteTrieSearch bufferSearch;
CustomFilterGroup(boolean startsWith, @NonNull String path) {
super(YouTubeAndMusicSettings.CUSTOM_FILTER, path);
this.startsWith = startsWith;
}
void addBufferString(@NonNull String bufferString) {
if (bufferSearch == null) {
bufferSearch = new ByteTrieSearch();
}
bufferSearch.addPattern(bufferString.getBytes());
}
@NonNull
@Override
public String toString() {
StringBuilder builder = new StringBuilder();
builder.append("CustomFilterGroup{");
builder.append("path=");
if (startsWith) builder.append(SYNTAX_STARTS_WITH);
builder.append(filters[0]);
if (bufferSearch != null) {
String delimitingCharacter = "";
builder.append(", bufferStrings=");
builder.append(delimitingCharacter);
for (byte[] bufferString : bufferSearch.getPatterns()) {
builder.append(new String(bufferString));
builder.append(delimitingCharacter);
}
}
builder.append("}");
return builder.toString();
}
}
public CustomFilter() {
Collection<CustomFilterGroup> groups = CustomFilterGroup.parseCustomFilterGroups();
if (!groups.isEmpty()) {
CustomFilterGroup[] groupsArray = groups.toArray(new CustomFilterGroup[0]);
Logger.printDebug(()-> "Using Custom filters: " + Arrays.toString(groupsArray));
addPathCallbacks(groupsArray);
}
}
@Override
public boolean isFiltered(String identifier, String path, byte[] buffer,
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
// All callbacks are custom filter groups.
CustomFilterGroup custom = (CustomFilterGroup) matchedGroup;
if (custom.startsWith && contentIndex != 0) {
return false;
}
if (custom.bufferSearch == null) {
return true; // No buffer filter, only path filtering.
}
return custom.bufferSearch.matches(buffer);
}
}

View file

@ -0,0 +1,76 @@
package app.revanced.extension.shared.patches.litho;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import app.revanced.extension.shared.patches.litho.FilterGroup.StringFilterGroup;
import app.revanced.extension.shared.patches.litho.FilterGroup.ByteArrayFilterGroup;
/**
* Filters litho based components.
*
* Callbacks to filter content are added using {@link #addIdentifierCallbacks(StringFilterGroup...)}
* and {@link #addPathCallbacks(StringFilterGroup...)}.
*
* To filter {@link FilterContentType#PROTOBUFFER}, first add a callback to
* either an identifier or a path.
* Then inside {@link #isFiltered(String, String, byte[], StringFilterGroup, FilterContentType, int)}
* search for the buffer content using either a {@link ByteArrayFilterGroup} (if searching for 1 pattern)
* or a {@link FilterGroupList.ByteArrayFilterGroupList} (if searching for more than 1 pattern).
*
* All callbacks must be registered before the constructor completes.
*/
public abstract class Filter {
public enum FilterContentType {
IDENTIFIER,
PATH,
PROTOBUFFER
}
/**
* Identifier callbacks. Do not add to this instance,
* and instead use {@link #addIdentifierCallbacks(StringFilterGroup...)}.
*/
protected final List<StringFilterGroup> identifierCallbacks = new ArrayList<>();
/**
* Path callbacks. Do not add to this instance,
* and instead use {@link #addPathCallbacks(StringFilterGroup...)}.
*/
protected final List<StringFilterGroup> pathCallbacks = new ArrayList<>();
/**
* Adds callbacks to {@link #isFiltered(String, String, byte[], StringFilterGroup, FilterContentType, int)}
* if any of the groups are found.
*/
protected final void addIdentifierCallbacks(StringFilterGroup... groups) {
identifierCallbacks.addAll(Arrays.asList(groups));
}
/**
* Adds callbacks to {@link #isFiltered(String, String, byte[], StringFilterGroup, FilterContentType, int)}
* if any of the groups are found.
*/
protected final void addPathCallbacks(StringFilterGroup... groups) {
pathCallbacks.addAll(Arrays.asList(groups));
}
/**
* Called after an enabled filter has been matched.
* Default implementation is to always filter the matched component and log the action.
* Subclasses can perform additional or different checks if needed.
* <p>
* Method is called off the main thread.
*
* @param matchedGroup The actual filter that matched.
* @param contentType The type of content matched.
* @param contentIndex Matched index of the identifier or path.
* @return True if the litho component should be filtered out.
*/
public boolean isFiltered(String identifier, String path, byte[] buffer,
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
return true;
}
}

View file

@ -0,0 +1,213 @@
package app.revanced.extension.shared.patches.litho;
import androidx.annotation.NonNull;
import app.revanced.extension.shared.ByteTrieSearch;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.settings.BooleanSetting;
public abstract class FilterGroup<T> {
public final static class FilterGroupResult {
private BooleanSetting setting;
private int matchedIndex;
private int matchedLength;
// In the future it might be useful to include which pattern matched,
// but for now that is not needed.
FilterGroupResult() {
this(null, -1, 0);
}
FilterGroupResult(BooleanSetting setting, int matchedIndex, int matchedLength) {
setValues(setting, matchedIndex, matchedLength);
}
public void setValues(BooleanSetting setting, int matchedIndex, int matchedLength) {
this.setting = setting;
this.matchedIndex = matchedIndex;
this.matchedLength = matchedLength;
}
/**
* A null value if the group has no setting,
* or if no match is returned from {@link FilterGroupList#check(Object)}.
*/
public BooleanSetting getSetting() {
return setting;
}
public boolean isFiltered() {
return matchedIndex >= 0;
}
/**
* Matched index of first pattern that matched, or -1 if nothing matched.
*/
public int getMatchedIndex() {
return matchedIndex;
}
/**
* Length of the matched filter pattern.
*/
public int getMatchedLength() {
return matchedLength;
}
}
protected final BooleanSetting setting;
protected final T[] filters;
/**
* Initialize a new filter group.
*
* @param setting The associated setting.
* @param filters The filters.
*/
@SafeVarargs
public FilterGroup(final BooleanSetting setting, final T... filters) {
this.setting = setting;
this.filters = filters;
if (filters.length == 0) {
throw new IllegalArgumentException("Must use one or more filter patterns (zero specified)");
}
}
public boolean isEnabled() {
return setting == null || setting.get();
}
/**
* @return If {@link FilterGroupList} should include this group when searching.
* By default, all filters are included except non enabled settings that require reboot.
*/
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
public boolean includeInSearch() {
return isEnabled() || !setting.rebootApp;
}
@NonNull
@Override
public String toString() {
return getClass().getSimpleName() + ": " + (setting == null ? "(null setting)" : setting);
}
public abstract FilterGroupResult check(final T stack);
public static class StringFilterGroup extends FilterGroup<String> {
public StringFilterGroup(final BooleanSetting setting, final String... filters) {
super(setting, filters);
}
@Override
public FilterGroupResult check(final String string) {
int matchedIndex = -1;
int matchedLength = 0;
if (isEnabled()) {
for (String pattern : filters) {
if (!string.isEmpty()) {
final int indexOf = string.indexOf(pattern);
if (indexOf >= 0) {
matchedIndex = indexOf;
matchedLength = pattern.length();
break;
}
}
}
}
return new FilterGroupResult(setting, matchedIndex, matchedLength);
}
}
/**
* If you have more than 1 filter patterns, then all instances of
* this class should filtered using {@link FilterGroupList.ByteArrayFilterGroupList#check(byte[])},
* which uses a prefix tree to give better performance.
*/
public static class ByteArrayFilterGroup extends FilterGroup<byte[]> {
private volatile int[][] failurePatterns;
// Modified implementation from https://stackoverflow.com/a/1507813
private static int indexOf(final byte[] data, final byte[] pattern, final int[] failure) {
// Finds the first occurrence of the pattern in the byte array using
// KMP matching algorithm.
int patternLength = pattern.length;
for (int i = 0, j = 0, dataLength = data.length; i < dataLength; i++) {
while (j > 0 && pattern[j] != data[i]) {
j = failure[j - 1];
}
if (pattern[j] == data[i]) {
j++;
}
if (j == patternLength) {
return i - patternLength + 1;
}
}
return -1;
}
private static int[] createFailurePattern(byte[] pattern) {
// Computes the failure function using a boot-strapping process,
// where the pattern is matched against itself.
final int patternLength = pattern.length;
final int[] failure = new int[patternLength];
for (int i = 1, j = 0; i < patternLength; i++) {
while (j > 0 && pattern[j] != pattern[i]) {
j = failure[j - 1];
}
if (pattern[j] == pattern[i]) {
j++;
}
failure[i] = j;
}
return failure;
}
public ByteArrayFilterGroup(BooleanSetting setting, byte[]... filters) {
super(setting, filters);
}
/**
* Converts the Strings into byte arrays. Used to search for text in binary data.
*/
public ByteArrayFilterGroup(BooleanSetting setting, String... filters) {
super(setting, ByteTrieSearch.convertStringsToBytes(filters));
}
private synchronized void buildFailurePatterns() {
if (failurePatterns != null) return; // Thread race and another thread already initialized the search.
Logger.printDebug(() -> "Building failure array for: " + this);
int[][] failurePatterns = new int[filters.length][];
int i = 0;
for (byte[] pattern : filters) {
failurePatterns[i++] = createFailurePattern(pattern);
}
this.failurePatterns = failurePatterns; // Must set after initialization finishes.
}
@Override
public FilterGroupResult check(final byte[] bytes) {
int matchedLength = 0;
int matchedIndex = -1;
if (isEnabled()) {
int[][] failures = failurePatterns;
if (failures == null) {
buildFailurePatterns(); // Lazy load.
failures = failurePatterns;
}
for (int i = 0, length = filters.length; i < length; i++) {
byte[] filter = filters[i];
matchedIndex = indexOf(bytes, filter, failures[i]);
if (matchedIndex >= 0) {
matchedLength = filter.length;
break;
}
}
}
return new FilterGroupResult(setting, matchedIndex, matchedLength);
}
}
}

View file

@ -0,0 +1,70 @@
package app.revanced.extension.shared.patches.litho;
import androidx.annotation.NonNull;
import java.util.*;
import app.revanced.extension.shared.ByteTrieSearch;
import app.revanced.extension.shared.StringTrieSearch;
import app.revanced.extension.shared.TrieSearch;
import app.revanced.extension.shared.patches.litho.FilterGroup.ByteArrayFilterGroup;
import app.revanced.extension.shared.patches.litho.FilterGroup.StringFilterGroup;
public abstract class FilterGroupList<V, T extends FilterGroup<V>> implements Iterable<T> {
private final List<T> filterGroups = new ArrayList<>();
private final TrieSearch<V> search = createSearchGraph();
@SafeVarargs
public final void addAll(final T... groups) {
filterGroups.addAll(Arrays.asList(groups));
for (T group : groups) {
if (!group.includeInSearch()) {
continue;
}
for (V pattern : group.filters) {
search.addPattern(pattern, (textSearched, matchedStartIndex, matchedLength, callbackParameter) -> {
if (group.isEnabled()) {
FilterGroup.FilterGroupResult result = (FilterGroup.FilterGroupResult) callbackParameter;
result.setValues(group.setting, matchedStartIndex, matchedLength);
return true;
}
return false;
});
}
}
}
@NonNull
@Override
public Iterator<T> iterator() {
return filterGroups.iterator();
}
public FilterGroup.FilterGroupResult check(V stack) {
FilterGroup.FilterGroupResult result = new FilterGroup.FilterGroupResult();
search.matches(stack, result);
return result;
}
protected abstract TrieSearch<V> createSearchGraph();
public static final class StringFilterGroupList extends FilterGroupList<String, StringFilterGroup> {
protected StringTrieSearch createSearchGraph() {
return new StringTrieSearch();
}
}
/**
* If searching for a single byte pattern, then it is slightly better to use
* {@link ByteArrayFilterGroup#check(byte[])} as it uses KMP which is faster
* than a prefix tree to search for only 1 pattern.
*/
public static final class ByteArrayFilterGroupList extends FilterGroupList<byte[], ByteArrayFilterGroup> {
protected ByteTrieSearch createSearchGraph() {
return new ByteTrieSearch();
}
}
}

View file

@ -0,0 +1,252 @@
package app.revanced.extension.shared.patches.litho;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.nio.ByteBuffer;
import java.util.List;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.StringTrieSearch;
import app.revanced.extension.shared.settings.BaseSettings;
import app.revanced.extension.shared.settings.YouTubeAndMusicSettings;
import app.revanced.extension.shared.patches.litho.FilterGroup.StringFilterGroup;
@SuppressWarnings("unused")
public final class LithoFilterPatch {
/**
* Simple wrapper to pass the litho parameters through the prefix search.
*/
private static final class LithoFilterParameters {
final String identifier;
final String path;
final byte[] buffer;
LithoFilterParameters(String lithoIdentifier, String lithoPath, byte[] buffer) {
this.identifier = lithoIdentifier;
this.path = lithoPath;
this.buffer = buffer;
}
@NonNull
@Override
public String toString() {
// Estimate the percentage of the buffer that are Strings.
StringBuilder builder = new StringBuilder(Math.max(100, buffer.length / 2));
builder.append( "ID: ");
builder.append(identifier);
builder.append(" Path: ");
builder.append(path);
if (YouTubeAndMusicSettings.DEBUG_PROTOBUFFER.get()) {
builder.append(" BufferStrings: ");
findAsciiStrings(builder, buffer);
}
return builder.toString();
}
/**
* Search through a byte array for all ASCII strings.
*/
static void findAsciiStrings(StringBuilder builder, byte[] buffer) {
// Valid ASCII values (ignore control characters).
final int minimumAscii = 32; // 32 = space character
final int maximumAscii = 126; // 127 = delete character
final int minimumAsciiStringLength = 4; // Minimum length of an ASCII string to include.
String delimitingCharacter = ""; // Non ascii character, to allow easier log filtering.
final int length = buffer.length;
int start = 0;
int end = 0;
while (end < length) {
int value = buffer[end];
if (value < minimumAscii || value > maximumAscii || end == length - 1) {
if (end - start >= minimumAsciiStringLength) {
for (int i = start; i < end; i++) {
builder.append((char) buffer[i]);
}
builder.append(delimitingCharacter);
}
start = end + 1;
}
end++;
}
}
}
/**
* Litho layout fixed thread pool size override.
* <p>
* Unpatched YouTube uses a layout fixed thread pool between 1 and 3 threads:
* <pre>
* 1 thread - > Device has less than 6 cores
* 2 threads -> Device has over 6 cores and less than 6GB of memory
* 3 threads -> Device has over 6 cores and more than 6GB of memory
* </pre>
*
* Using more than 1 thread causes layout issues such as the You tab watch/playlist shelf
* that is sometimes incorrectly hidden (ReVanced is not hiding it), and seems to
* fix a race issue if using the active navigation tab status with litho filtering.
*/
private static final int LITHO_LAYOUT_THREAD_POOL_SIZE = 1;
/**
* Placeholder for actual filters.
*/
private static final class DummyFilter extends Filter { }
private static final Filter[] filters = new Filter[] {
new DummyFilter() // Replaced patching, do not touch.
};
private static final StringTrieSearch pathSearchTree = new StringTrieSearch();
private static final StringTrieSearch identifierSearchTree = new StringTrieSearch();
private static final byte[] EMPTY_BYTE_ARRAY = new byte[0];
/**
* Because litho filtering is multi-threaded and the buffer is passed in from a different injection point,
* the buffer is saved to a ThreadLocal so each calling thread does not interfere with other threads.
*/
private static final ThreadLocal<byte[]> bufferThreadLocal = new ThreadLocal<>();
static {
for (Filter filter : filters) {
filterUsingCallbacks(identifierSearchTree, filter,
filter.identifierCallbacks, Filter.FilterContentType.IDENTIFIER);
filterUsingCallbacks(pathSearchTree, filter,
filter.pathCallbacks, Filter.FilterContentType.PATH);
}
Logger.printDebug(() -> "Using: "
+ identifierSearchTree.numberOfPatterns() + " identifier filters"
+ " (" + identifierSearchTree.getEstimatedMemorySize() + " KB), "
+ pathSearchTree.numberOfPatterns() + " path filters"
+ " (" + pathSearchTree.getEstimatedMemorySize() + " KB)");
}
private static void filterUsingCallbacks(StringTrieSearch pathSearchTree,
Filter filter, List<StringFilterGroup> groups,
Filter.FilterContentType type) {
String filterSimpleName = filter.getClass().getSimpleName();
for (StringFilterGroup group : groups) {
if (!group.includeInSearch()) {
continue;
}
for (String pattern : group.filters) {
pathSearchTree.addPattern(pattern, (textSearched, matchedStartIndex,
matchedLength, callbackParameter) -> {
if (!group.isEnabled()) return false;
LithoFilterParameters parameters = (LithoFilterParameters) callbackParameter;
final boolean isFiltered = filter.isFiltered(parameters.identifier,
parameters.path, parameters.buffer, group, type, matchedStartIndex);
if (isFiltered && BaseSettings.DEBUG.get()) {
if (type == Filter.FilterContentType.IDENTIFIER) {
Logger.printDebug(() -> "Filtered " + filterSimpleName
+ " identifier: " + parameters.identifier);
} else {
Logger.printDebug(() -> "Filtered " + filterSimpleName
+ " path: " + parameters.path);
}
}
return isFiltered;
}
);
}
}
}
/**
* Injection point. Called off the main thread.
* Targets 20.22+
*/
public static void setProtoBuffer(byte[] buffer) {
// Set the buffer to a thread local. The buffer will remain in memory, even after the call to #filter completes.
// This is intentional, as it appears the buffer can be set once and then filtered multiple times.
// The buffer will be cleared from memory after a new buffer is set by the same thread,
// or when the calling thread eventually dies.
bufferThreadLocal.set(buffer);
}
/**
* Injection point. Called off the main thread.
* Targets 20.21 and lower.
*/
public static void setProtoBuffer(@Nullable ByteBuffer buffer) {
// Set the buffer to a thread local. The buffer will remain in memory, even after the call to #filter completes.
// This is intentional, as it appears the buffer can be set once and then filtered multiple times.
// The buffer will be cleared from memory after a new buffer is set by the same thread,
// or when the calling thread eventually dies.
if (buffer == null || !buffer.hasArray()) {
// It appears the buffer can be cleared out just before the call to #filter()
// Ignore this null value and retain the last buffer that was set.
Logger.printDebug(() -> "Ignoring null or empty buffer: " + buffer);
} else {
setProtoBuffer(buffer.array());
}
}
/**
* Injection point.
*/
public static boolean isFiltered(String lithoIdentifier, StringBuilder pathBuilder) {
try {
if (lithoIdentifier.isEmpty() && pathBuilder.length() == 0) {
return false;
}
byte[] buffer = bufferThreadLocal.get();
// Potentially the buffer may have been null or never set up until now.
// Use an empty buffer so the litho id/path filters still work correctly.
if (buffer == null) {
buffer = EMPTY_BYTE_ARRAY;
}
LithoFilterParameters parameter = new LithoFilterParameters(
lithoIdentifier, pathBuilder.toString(), buffer);
Logger.printDebug(() -> "Searching " + parameter);
if (identifierSearchTree.matches(parameter.identifier, parameter)) {
return true;
}
if (pathSearchTree.matches(parameter.path, parameter)) {
return true;
}
} catch (Exception ex) {
Logger.printException(() -> "isFiltered failure", ex);
}
return false;
}
/**
* Injection point.
*/
public static int getExecutorCorePoolSize(int originalCorePoolSize) {
if (originalCorePoolSize != LITHO_LAYOUT_THREAD_POOL_SIZE) {
Logger.printDebug(() -> "Overriding core thread pool size from: " + originalCorePoolSize
+ " to: " + LITHO_LAYOUT_THREAD_POOL_SIZE);
}
return LITHO_LAYOUT_THREAD_POOL_SIZE;
}
/**
* Injection point.
*/
public static int getExecutorMaxThreads(int originalMaxThreads) {
if (originalMaxThreads != LITHO_LAYOUT_THREAD_POOL_SIZE) {
Logger.printDebug(() -> "Overriding max thread pool size from: " + originalMaxThreads
+ " to: " + LITHO_LAYOUT_THREAD_POOL_SIZE);
}
return LITHO_LAYOUT_THREAD_POOL_SIZE;
}
}

View file

@ -0,0 +1,14 @@
package app.revanced.extension.shared.settings;
import static app.revanced.extension.shared.settings.Setting.parent;
import static java.lang.Boolean.FALSE;
public class YouTubeAndMusicSettings extends BaseSettings {
// Custom filter
public static final BooleanSetting CUSTOM_FILTER = new BooleanSetting("revanced_custom_filter", FALSE);
public static final StringSetting CUSTOM_FILTER_STRINGS = new StringSetting("revanced_custom_filter_strings", "", true, parent(CUSTOM_FILTER));
// Miscellaneous
public static final BooleanSetting DEBUG_PROTOBUFFER = new BooleanSetting("revanced_debug_protobuffer", FALSE, false,
"revanced_debug_protobuffer_user_dialog_message", parent(BaseSettings.DEBUG));
}